03대형 인용과 아바타 전환
큰 인용문 아래 얼굴 네 개가 늘어서고, 고른 얼굴이 바뀌는 순간 인용문도 한 프레임에 통째로 갈립니다.
쓰임대표 고객 한 명을 크게 보여 주는 기업 소개 섹션
steps(1, end)translateXaria-pressed
<main class="stage">
<section class="qs is-demo" aria-label="고객 후기 대형 인용">
<div class="qs__body" aria-live="polite">
<p class="qs__quote qs__quote--1 is-on">데이터를 처음 보는 사람도 어디를 고쳐야 하는지 바로 알게 됐습니다.</p>
<p class="qs__quote qs__quote--2">묻는 전화가 줄고, 주문은 저절로 들어옵니다.</p>
<p class="qs__quote qs__quote--3">회의가 짧아졌습니다. 화면이 대신 설명합니다.</p>
<p class="qs__quote qs__quote--4">처음 혼자 꾸린 사이트가 이제 동네에서 제일 커 보입니다.</p>
<p class="qs__by qs__by--1 is-on">운영팀 매니저 · 온라인 서점</p>
<p class="qs__by qs__by--2">사장 · 동네 빵집</p>
<p class="qs__by qs__by--3">디자인팀 팀장 · 수제 가구 공방</p>
<p class="qs__by qs__by--4">대표 · 꽃집</p>
</div>
<div class="qs__rail" role="group" aria-label="후기 보는 사람 고르기">
<span class="qs__ring" aria-hidden="true"></span>
<span class="qs__cell">
<button class="qs__face qs__face--1 is-on" type="button" aria-pressed="true"><span class="qs__initial" aria-hidden="true">K</span></button>
</span>
<span class="qs__cell">
<button class="qs__face qs__face--2" type="button" aria-pressed="false"><span class="qs__initial" aria-hidden="true">P</span></button>
</span>
<span class="qs__cell">
<button class="qs__face qs__face--3" type="button" aria-pressed="false"><span class="qs__initial" aria-hidden="true">Y</span></button>
</span>
<span class="qs__cell">
<button class="qs__face qs__face--4" type="button" aria-pressed="false"><span class="qs__initial" aria-hidden="true">H</span></button>
</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 276 소셜 프루프 팔레트 — 아홉 항목이 같은 값을 쓴다
$pf-amber: $stage-yellow; // 무대
$pf-ink: #1c1b22; // 앰버 위 글자·링 11.83:1 (흰 글자 금지 — 1.44:1)
$pf-cream: #fbf7ef; // 선택된 아바타 면
$duration: $dur-loop; // 인용 넷 + 링 네 칸이 한 바퀴 도는 시간
$easing: $ease-spring; // 링이 옆 칸으로 미끄러지는 곡선
$color: $pf-amber; // 무대(앰버)
@include stage($color);
.qs {
--cell: 56px; // 아바타 칸 한 칸 — 레일 폭과 링 이동의 기준
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
gap: $sp-4;
width: clamp(288px, 92vw, 392px);
}
// 인용 넉 장은 1행에, 이름줄 넉 장은 2행에 포갠다. 둘을 같은 칸에 포개면
// 두 줄짜리 인용을 골랐을 때 이름줄이 마지막 줄 위에 겹친다 (실측 2026-09-12, 클릭 경로)
.qs__body {
display: grid;
grid-template-rows: auto auto;
}
// 인용 넷을 같은 자리(grid-area: 1 / 1)에 포개고 컷으로 갈아 끼운다 — 크로스페이드 금지
.qs__quote {
grid-area: 1 / 1;
margin: 0;
color: $pf-ink;
font-weight: 700;
font-size: clamp(17px, 4.4vw, 22px);
line-height: 1.4;
opacity: 0;
visibility: hidden;
}
.qs__quote.is-on { opacity: 1; visibility: visible; }
.qs__by {
grid-area: 2 / 1;
align-self: start;
margin: 0;
padding-top: $sp-3;
color: $pf-ink;
font-weight: 500;
font-size: 12.5px;
line-height: 1.3;
opacity: 0;
visibility: hidden;
}
.qs__by.is-on { opacity: 1; visibility: visible; }
// 아바타 넷 — 균등 격자. 칸 사이 간격은 칸 안 여백으로 준다(gap 을 두면
// 링의 100% 이동이 칸 폭과 어긋난다). 레일 폭 = 칸 넷 → 1fr 이 칸 폭과 정확히 같다
.qs__rail {
--i: 0; // 링이 있는 칸 — JS 가 링 자신에 세팅한다
display: grid;
grid-template-columns: repeat(4, 1fr);
width: calc(4 * var(--cell));
}
// 링 — 첫 칸을 채우는 격자 항목. 자기 폭 = 칸 폭이므로
// translateX(calc(var(--i) * 100%)) 가 정확히 칸 단위 이동이 된다 (짐작한 px 아님)
.qs__ring {
grid-row: 1;
grid-column: 1;
border: 3px solid $pf-ink;
border-radius: 50%;
pointer-events: none;
transform: translateX(calc(var(--i) * 100%));
transition: transform $dur-base $easing;
}
// 칸은 열을 명시한다 — 링이 1열을 차지하므로 자동 배치가 셋을 한 칸씩 민다
.qs__cell {
grid-row: 1;
display: grid;
place-items: center;
width: var(--cell);
height: var(--cell);
}
.qs__cell:nth-child(2) { grid-column: 1; }
.qs__cell:nth-child(3) { grid-column: 2; }
.qs__cell:nth-child(4) { grid-column: 3; }
.qs__cell:nth-child(5) { grid-column: 4; }
.qs__face {
display: grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba($pf-ink, .14);
color: $pf-ink;
cursor: pointer;
transform: translateY(0);
transition: transform $dur-quick $ease-pop, background-color $dur-quick $ease-out;
}
.qs__initial {
font-weight: 700;
font-size: 15px;
line-height: 1;
}
.qs__face.is-on {
background: $pf-cream;
transform: translateY(-4px);
}
.qs__face:focus-visible {
outline: 2px solid $pf-ink;
outline-offset: 3px;
}
// ── 자동 순환 ────────────────────────────────────────────────
// 인용·이름은 25% 슬롯마다 steps(1, end) 컷으로 갈리고, 링은 컷 직전에 한 칸씩
// 미끄러져 프레임을 채운다 — 컷만 쓰면 움직인프레임이 2~3장으로 떨어진다.
// 같은 키프레임을 음수 delay 로 돌리면 슬롯 순서가 1→4→3→2 로 돈다:
// k번째 슬롯에 k번 요소를 세우려면 delay 를 -(2s - k*0.5s) 로 잡는다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.qs.is-demo .qs__quote,
.qs.is-demo .qs__by {
animation-name: qsCut;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
visibility: visible;
}
.qs.is-demo .qs__quote--1 { animation-delay: -2s; }
.qs.is-demo .qs__quote--2 { animation-delay: -1.5s; }
.qs.is-demo .qs__quote--3 { animation-delay: -1s; }
.qs.is-demo .qs__quote--4 { animation-delay: -.5s; }
.qs.is-demo .qs__by--1 { animation-delay: -2s; }
.qs.is-demo .qs__by--2 { animation-delay: -1.5s; }
.qs.is-demo .qs__by--3 { animation-delay: -1s; }
.qs.is-demo .qs__by--4 { animation-delay: -.5s; }
.qs.is-demo .qs__ring {
animation-name: qsRing;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qs.is-demo .qs__face {
animation-name: qsFacePop, qsFaceTone;
animation-duration: $duration, $duration;
animation-timing-function: $ease-pop, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
.qs.is-demo .qs__face--1 { animation-delay: -2s, -2s; }
.qs.is-demo .qs__face--2 { animation-delay: -1.5s, -1.5s; }
.qs.is-demo .qs__face--3 { animation-delay: -1s, -1s; }
.qs.is-demo .qs__face--4 { animation-delay: -.5s, -.5s; }
@keyframes qsCut {
0% { opacity: 1; }
25% { opacity: 0; }
100% { opacity: 0; }
}
// 링은 컷(25% 경계) 직전에 도착하고, 마지막 칸에서 첫 칸으로의 복귀는
// 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이다
@keyframes qsRing {
0%, 16% { transform: translateX(0); }
24.5%, 41% { transform: translateX(100%); }
49.5%, 66% { transform: translateX(200%); }
74.5%, 79% { transform: translateX(300%); }
88%, 100% { transform: translateX(0); }
}
// 선택된 아바타는 살짝 튀었다가 슬롯 안에 내려온다
@keyframes qsFacePop {
0% { transform: translateY(0); }
6% { transform: translateY(-4px); }
19% { transform: translateY(-4px); }
25%,100% { transform: translateY(0); }
}
// 면색은 컷으로 갈아 끼운다 — 크로스페이드는 poster 에 반투명 유령을 남긴다
@keyframes qsFaceTone {
0% { background-color: $pf-cream; }
25%,100% { background-color: rgba($pf-ink, .14); }
}
// 좁은 화면 — 글자와 칸만 줄이고 구조는 그대로
@media (max-width: 340px), (max-height: 220px) {
.qs { gap: $sp-2; width: 100%; }
.qs__quote { font-size: 13.5px; line-height: 1.45; }
.qs__by { font-size: 10.5px; padding-top: $sp-2; }
.qs { --cell: 40px; }
.qs__face { width: 32px; height: 32px; }
.qs__initial { font-size: 12px; }
.qs__ring { border-width: 2px; }
}
@media (prefers-reduced-motion: reduce) {
// .qs.is-demo .qs__quote (0,3,0) 을 이기려면 !important 가 필요하다.
// 움직임만 끄고 .is-on 인용·이름은 보인다
.qs__quote, .qs__by, .qs__ring, .qs__face {
animation: none !important;
transition: none !important;
}
.qs__quote, .qs__by { opacity: 0 !important; visibility: hidden !important; }
.qs__quote.is-on, .qs__by.is-on { opacity: 1 !important; visibility: visible !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.qs {
--cell: 56px;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
gap: 16px;
width: clamp(288px, 92vw, 392px);
}
.qs__body {
display: grid;
grid-template-rows: auto auto;
}
.qs__quote {
grid-area: 1/1;
margin: 0;
color: #1c1b22;
font-weight: 700;
font-size: clamp(17px, 4.4vw, 22px);
line-height: 1.4;
opacity: 0;
visibility: hidden;
}
.qs__quote.is-on {
opacity: 1;
visibility: visible;
}
.qs__by {
grid-area: 2/1;
align-self: start;
margin: 0;
padding-top: 12px;
color: #1c1b22;
font-weight: 500;
font-size: 12.5px;
line-height: 1.3;
opacity: 0;
visibility: hidden;
}
.qs__by.is-on {
opacity: 1;
visibility: visible;
}
.qs__rail {
--i: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
width: calc(4 * var(--cell));
}
.qs__ring {
grid-row: 1;
grid-column: 1;
border: 3px solid #1c1b22;
border-radius: 50%;
pointer-events: none;
transform: translateX(calc(var(--i) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.qs__cell {
grid-row: 1;
display: grid;
place-items: center;
width: var(--cell);
height: var(--cell);
}
.qs__cell:nth-child(2) {
grid-column: 1;
}
.qs__cell:nth-child(3) {
grid-column: 2;
}
.qs__cell:nth-child(4) {
grid-column: 3;
}
.qs__cell:nth-child(5) {
grid-column: 4;
}
.qs__face {
display: grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(28, 27, 34, 0.14);
color: #1c1b22;
cursor: pointer;
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms ease-out;
}
.qs__initial {
font-weight: 700;
font-size: 15px;
line-height: 1;
}
.qs__face.is-on {
background: #fbf7ef;
transform: translateY(-4px);
}
.qs__face:focus-visible {
outline: 2px solid #1c1b22;
outline-offset: 3px;
}
.qs.is-demo .qs__quote,
.qs.is-demo .qs__by {
animation-name: qsCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
visibility: visible;
}
.qs.is-demo .qs__quote--1 {
animation-delay: -2s;
}
.qs.is-demo .qs__quote--2 {
animation-delay: -1.5s;
}
.qs.is-demo .qs__quote--3 {
animation-delay: -1s;
}
.qs.is-demo .qs__quote--4 {
animation-delay: -0.5s;
}
.qs.is-demo .qs__by--1 {
animation-delay: -2s;
}
.qs.is-demo .qs__by--2 {
animation-delay: -1.5s;
}
.qs.is-demo .qs__by--3 {
animation-delay: -1s;
}
.qs.is-demo .qs__by--4 {
animation-delay: -0.5s;
}
.qs.is-demo .qs__ring {
animation-name: qsRing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qs.is-demo .qs__face {
animation-name: qsFacePop, qsFaceTone;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
.qs.is-demo .qs__face--1 {
animation-delay: -2s, -2s;
}
.qs.is-demo .qs__face--2 {
animation-delay: -1.5s, -1.5s;
}
.qs.is-demo .qs__face--3 {
animation-delay: -1s, -1s;
}
.qs.is-demo .qs__face--4 {
animation-delay: -0.5s, -0.5s;
}
@keyframes qsCut {
0% {
opacity: 1;
}
25% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes qsRing {
0%, 16% {
transform: translateX(0);
}
24.5%, 41% {
transform: translateX(100%);
}
49.5%, 66% {
transform: translateX(200%);
}
74.5%, 79% {
transform: translateX(300%);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes qsFacePop {
0% {
transform: translateY(0);
}
6% {
transform: translateY(-4px);
}
19% {
transform: translateY(-4px);
}
25%, 100% {
transform: translateY(0);
}
}
@keyframes qsFaceTone {
0% {
background-color: #fbf7ef;
}
25%, 100% {
background-color: rgba(28, 27, 34, 0.14);
}
}
@media (max-width: 340px), (max-height: 220px) {
.qs {
gap: 8px;
width: 100%;
}
.qs__quote {
font-size: 13.5px;
line-height: 1.45;
}
.qs__by {
font-size: 10.5px;
padding-top: 8px;
}
.qs {
--cell: 40px;
}
.qs__face {
width: 32px;
height: 32px;
}
.qs__initial {
font-size: 12px;
}
.qs__ring {
border-width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.qs__quote, .qs__by, .qs__ring, .qs__face {
animation: none !important;
transition: none !important;
}
.qs__quote, .qs__by {
opacity: 0 !important;
visibility: hidden !important;
}
.qs__quote.is-on, .qs__by.is-on {
opacity: 1 !important;
visibility: visible !important;
}
}(function () {
var root = document.querySelector('.qs');
var faces = [].slice.call(root.querySelectorAll('.qs__face'));
var quotes = [].slice.call(root.querySelectorAll('.qs__quote'));
var bys = [].slice.call(root.querySelectorAll('.qs__by'));
var ring = root.querySelector('.qs__ring');
// 정적 경로 — .is-demo 를 떼면 .is-on 넷 중 하나만 보인다
function pick(i) {
root.classList.remove('is-demo');
faces.forEach(function (f, n) {
f.classList.toggle('is-on', n === i);
f.setAttribute('aria-pressed', n === i ? 'true' : 'false');
});
quotes.forEach(function (q, n) { q.classList.toggle('is-on', n === i); });
bys.forEach(function (b, n) { b.classList.toggle('is-on', n === i); });
// 링은 자기 칸 폭 기준(100%)으로 옮긴다 — 짐작한 px 를 쓰지 않는다.
// --i 는 링 "자신"에 걸어야 먹는다 (부모·트랙이 아니라)
ring.style.setProperty('--i', String(i));
}
faces.forEach(function (f, i) {
f.addEventListener('click', function () { pick(i); });
f.addEventListener('keydown', function (e) {
var to = -1;
if (e.key === 'ArrowRight') { to = (i + 1) % faces.length; }
if (e.key === 'ArrowLeft') { to = (i - 1 + faces.length) % faces.length; }
if (to < 0) { return; }
e.preventDefault();
pick(to);
faces[to].focus({ preventScroll: true });
});
});
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./QuoteSwitcher.module.scss";
/* ambient 에 React 이벤트 제네릭이 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
export type SwitcherItem = {
quote: string;
by: string;
/** 아바타 이니셜 한 글자 */
initial: string;
};
export type QuoteSwitcherProps = {
sectionLabel?: string;
groupLabel?: string;
items?: SwitcherItem[];
/** 인용 넷 + 링 네 칸이 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--i"?: string;
};
const DEFAULT_ITEMS: SwitcherItem[] = [
{ quote: "데이터를 처음 보는 사람도 어디를 고쳐야 하는지 바로 알게 됐습니다.", by: "운영팀 매니저 · 온라인 서점", initial: "K" },
{ quote: "묻는 전화가 줄고, 주문은 저절로 들어옵니다.", by: "사장 · 동네 빵집", initial: "P" },
{ quote: "회의가 짧아졌습니다. 화면이 대신 설명합니다.", by: "디자인팀 팀장 · 수제 가구 공방", initial: "Y" },
{ quote: "처음 혼자 꾸린 사이트가 이제 동네에서 제일 커 보입니다.", by: "대표 · 꽃집", initial: "H" },
];
export default function QuoteSwitcher({
sectionLabel = "고객 후기 대형 인용",
groupLabel = "후기 보는 사람 고르기",
items = DEFAULT_ITEMS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: QuoteSwitcherProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [active, setActive] = useState<number>(0);
const faceRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--i": `${active}`,
};
/* 정적 경로 — 얼굴을 고르면 자동 순환이 끝기고 그 사람의 인용문으로 바뀐다 */
const pick = (i: number): void => {
setLoop(false);
setActive(i);
};
const onFaceKey = (e: KeyEvt, i: number): void => {
const list = faceRefs.current;
if (!list) { return; }
const n = list.length;
let to = -1;
if (e.key === "ArrowRight") { to = (i + 1) % n; }
if (e.key === "ArrowLeft") { to = (i - 1 + n) % n; }
if (to < 0) { return; }
e.preventDefault();
pick(to);
const btn = list[to];
if (btn) { btn.focus({ preventScroll: true }); }
};
return (
<section
className={loop ? `${styles.qs} ${styles.isDemo}` : styles.qs}
style={vars}
aria-label={sectionLabel}
>
<div className={styles.body} aria-live="polite">
{items.map((it: SwitcherItem, i: number) => (
<p className={active === i ? `${styles.quote} ${styles.isOn}` : styles.quote} key={it.by}>
{it.quote}
</p>
))}
{items.map((it: SwitcherItem, i: number) => (
<p className={active === i ? `${styles.by} ${styles.isOn}` : styles.by} key={`${it.by}-by`}>
{it.by}
</p>
))}
</div>
<div className={styles.rail} role="group" aria-label={groupLabel}>
<span className={styles.ring} aria-hidden="true" style={{ "--i": `${active}` } as Vars} />
{items.map((it: SwitcherItem, i: number) => (
<span className={styles.cell} key={it.by}>
<button
className={active === i ? `${styles.face} ${styles.isOn}` : styles.face}
type="button"
aria-pressed={active === i}
ref={(el: HTMLButtonElement | null) => { faceRefs.current[i] = el; }}
onClick={() => pick(i)}
onKeyDown={(e: KeyEvt) => onFaceKey(e, i)}
>
<span className={styles.initial} aria-hidden="true">{it.initial}</span>
</button>
</span>
))}
</div>
</section>
);
}03 대형 인용과 아바타 전환 — 측정 누적면적 15.302% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 고객 후기 디자인 패턴 9종, 신뢰를 보여 주는
이 데모가 속한 글
고객 후기 디자인 패턴 9종, 신뢰를 보여 주는고객 후기 디자인은 다른 사람이 남긴 평가를 화면에 얹어, 망설이는 방문자를 마지막으로 안심시키는 섹션입니다. 아홉 가지는 후기 카드·별점·고객사 목록을 HTML 과 CSS 만으로 지어서 옮겨 붙이면 그대로 움직입니 이 글의 데모 전부 보기


