02높이 다른 인용 그리드
길이가 제각각인 후기 카드가 높이를 맞추지 않고 벽돌처럼 쌓이며, 차례로 한 장씩 앞으로 나왔다 들어갑니다.
쓰임후기 수가 많은 제품 페이지 · 후기 모음 페이지
grid-rowscale:focus-visible

<main class="stage">
<section class="qm is-demo" aria-label="높이 다른 후기 인용 그리드">
<figure class="qm__card qm__card--1" tabindex="0">
<svg class="qm__mark" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2"/><path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z"/></svg>
<blockquote class="qm__q"><p class="qm__t">후기부터 본다는 말을 손님에게 들었습니다</p></blockquote>
<figcaption class="qm__by">온라인 서점 운영팀</figcaption>
</figure>
<figure class="qm__card qm__card--2" tabindex="0">
<svg class="qm__mark" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2"/><path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z"/></svg>
<blockquote class="qm__q"><p class="qm__t">설명이 길지 않은데도 무엇이 좋아졌는지 알겠다는 반응이 많았습니다</p></blockquote>
<figcaption class="qm__by">동네 빵집 사장</figcaption>
</figure>
<figure class="qm__card qm__card--3" tabindex="0">
<svg class="qm__mark" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2"/><path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z"/></svg>
<blockquote class="qm__q"><p class="qm__t">재구매가 늘었습니다</p></blockquote>
<figcaption class="qm__by">수제 가구 공방 대표</figcaption>
</figure>
<figure class="qm__card qm__card--4" tabindex="0">
<svg class="qm__mark" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2"/><path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z"/></svg>
<blockquote class="qm__q"><p class="qm__t">붙여 넣고 폰트만 바꿨더니 그대로 어울렸습니다</p></blockquote>
<figcaption class="qm__by">꽃집 운영자</figcaption>
</figure>
<figure class="qm__card qm__card--5" tabindex="0">
<svg class="qm__mark" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2"/><path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z"/></svg>
<blockquote class="qm__q"><p class="qm__t">장바구니 이탈이 줄었습니다</p></blockquote>
<figcaption class="qm__by">문구점 점장</figcaption>
</figure>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 276 소셜 프루프 팔레트 — 아홉 항목이 같은 값을 쓴다
$pf-cream: #fbf7ef; // 카드 바탕 — 흰 무대 위 1.07:1 면(비-글자)
$pf-paper: $stage-paper;
$pf-ink: #1c1b22; // 인용 글자 15.99:1
$pf-stone: #565062; // 이름줄 7.23:1
$pf-teal: #479089; // 인용 마크
$pf-violet: #a49cce; // 인용 마크 두 번째 색
$duration: $dur-loop; // 다섯 장이 차례로 한 번씩 나왔다 들어가는 한 바퀴
$easing: $ease-pop; // 앞으로 나올 때의 튕김
$color: $pf-paper; // 무대(흰 종이)
@include stage($color);
.qm {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(2, 1fr);
// 잘게 쪼갠 행 — 카드마다 span 수로 제각각인 높이를 벽돌처럼 쌓는다.
// 행 사이 gap 을 0 으로 둔다: gap 은 8px 행 "마다" 열리므로 그대로 두면
// span 12 가 96px 가 아니라 96+gap*11 로 부풀어 무대를 넘친다 (실측)
grid-auto-rows: 8px;
grid-auto-flow: row dense;
column-gap: $sp-2;
row-gap: 0;
width: clamp(288px, 94vw, 420px);
margin: 0;
}
.qm__card {
display: grid;
align-content: start;
// min-content 기여로 8px 행이 저절로 크지 않게 한다
min-height: 0;
gap: $sp-1;
margin: 0;
padding: $sp-2;
border: 1px solid rgba($pf-ink, .1);
border-radius: $r-card;
background: $pf-cream;
box-shadow: 0 1px 2px rgba($pf-ink, .06);
transform: translateY(0) scale(1);
transition: transform $dur-base $easing, box-shadow $dur-base $ease-out;
}
// 다섯 장의 span — 왼열 3장(12+9+9=30행) · 오른열 2장(14+12=26행).
// 1줄 카드 9행(72px) · 2줄 12행(96px) · 3줄 14행(112px) = 내용+테두리 올림
.qm__card--1 { grid-row: span 12; }
.qm__card--2 { grid-row: span 14; }
.qm__card--3 { grid-row: span 9; }
.qm__card--4 { grid-row: span 12; }
.qm__card--5 { grid-row: span 9; }
.qm__mark {
width: 12px;
height: 12px;
color: rgba($pf-teal, .72);
}
.qm__card--2 .qm__mark,
.qm__card--4 .qm__mark { color: rgba($pf-violet, .72); }
// 시각 요소 reset — p·figure·blockquote 기본 여백을 끈다
.qm__q { margin: 0; }
.qm__t {
margin: 0;
color: $pf-ink;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12.5px);
line-height: 1.5;
}
.qm__by {
color: $pf-stone;
font-weight: 500;
font-size: 10.5px;
line-height: 1.3;
}
// 실제 조작 경로 — 호버·키보드 포커스에서 같은 확대가 transition 으로 일어난다
.qm__card:hover,
.qm__card:focus-visible {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba($pf-ink, .16);
}
.qm__card:focus-visible {
outline: 2px solid $pf-teal;
outline-offset: 2px;
}
// ── 자동 순번 ────────────────────────────────────────────────
// 카드가 20% 씩 어긋나 한 장씩 앞으로 나왔다 들어간다 (복귀는 90% 안에).
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.qm.is-demo .qm__card {
animation-name: qmStep;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
transition: none;
}
.qm.is-demo .qm__card--2 { animation-delay: -.4s; }
.qm.is-demo .qm__card--3 { animation-delay: -.8s; }
.qm.is-demo .qm__card--4 { animation-delay: -1.2s; }
.qm.is-demo .qm__card--5 { animation-delay: -1.6s; }
@keyframes qmStep {
0% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba($pf-ink, .06);
}
4% {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba($pf-ink, .16);
}
15% {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba($pf-ink, .16);
}
19%, 100% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba($pf-ink, .06);
}
}
// 좁은 화면용 들어올림 — 세로 여유가 7px 남아서 폭을 줄인다
@keyframes qmStepSm {
0% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba($pf-ink, .06);
}
4% {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 10px 16px rgba($pf-ink, .14);
}
15% {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 10px 16px rgba($pf-ink, .14);
}
19%, 100% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba($pf-ink, .06);
}
}
// 좁은 화면 — 마크를 감추고 간격을 내려 20행(160px) 안에 맞춘다.
// 들어올림도 -4px/1.03 으로 줄인다(세로 여유 7.2px).
// grid-auto-rows 의 8px 은 간격 눈금(BD17)이 아니라 행 단위라 그대로 둔다
@media (max-width: 340px), (max-height: 220px) {
.qm { column-gap: $sp-1; width: 100%; }
.qm__card { gap: $sp-1; padding: $sp-1; }
.qm__mark { display: none; }
.qm.is-demo .qm__card { animation-name: qmStepSm; }
.qm__card:hover,
.qm__card:focus-visible { transform: translateY(-4px) scale(1.03); }
.qm__card--1 { grid-row: span 8; }
.qm__card--2 { grid-row: span 10; }
.qm__card--3 { grid-row: span 6; }
.qm__card--4 { grid-row: span 8; }
.qm__card--5 { grid-row: span 6; }
.qm__t { font-size: 10px; line-height: 1.45; }
.qm__by { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
// .qm.is-demo .qm__card (0,3,0) 를 이기려면 !important 가 필요하다
.qm__card { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.qm {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 8px;
grid-auto-flow: row dense;
column-gap: 8px;
row-gap: 0;
width: clamp(288px, 94vw, 420px);
margin: 0;
}
.qm__card {
display: grid;
align-content: start;
min-height: 0;
gap: 4px;
margin: 0;
padding: 8px;
border: 1px solid rgba(28, 27, 34, 0.1);
border-radius: 16px;
background: #fbf7ef;
box-shadow: 0 1px 2px rgba(28, 27, 34, 0.06);
transform: translateY(0) scale(1);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 300ms ease-out;
}
.qm__card--1 {
grid-row: span 12;
}
.qm__card--2 {
grid-row: span 14;
}
.qm__card--3 {
grid-row: span 9;
}
.qm__card--4 {
grid-row: span 12;
}
.qm__card--5 {
grid-row: span 9;
}
.qm__mark {
width: 12px;
height: 12px;
color: rgba(71, 144, 137, 0.72);
}
.qm__card--2 .qm__mark,
.qm__card--4 .qm__mark {
color: rgba(164, 156, 206, 0.72);
}
.qm__q {
margin: 0;
}
.qm__t {
margin: 0;
color: #1c1b22;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12.5px);
line-height: 1.5;
}
.qm__by {
color: #565062;
font-weight: 500;
font-size: 10.5px;
line-height: 1.3;
}
.qm__card:hover,
.qm__card:focus-visible {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba(28, 27, 34, 0.16);
}
.qm__card:focus-visible {
outline: 2px solid #479089;
outline-offset: 2px;
}
.qm.is-demo .qm__card {
animation-name: qmStep;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
transition: none;
}
.qm.is-demo .qm__card--2 {
animation-delay: -0.4s;
}
.qm.is-demo .qm__card--3 {
animation-delay: -0.8s;
}
.qm.is-demo .qm__card--4 {
animation-delay: -1.2s;
}
.qm.is-demo .qm__card--5 {
animation-delay: -1.6s;
}
@keyframes qmStep {
0% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba(28, 27, 34, 0.06);
}
4% {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba(28, 27, 34, 0.16);
}
15% {
transform: translateY(-6px) scale(1.04);
box-shadow: 0 14px 24px rgba(28, 27, 34, 0.16);
}
19%, 100% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba(28, 27, 34, 0.06);
}
}
@keyframes qmStepSm {
0% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba(28, 27, 34, 0.06);
}
4% {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 10px 16px rgba(28, 27, 34, 0.14);
}
15% {
transform: translateY(-4px) scale(1.03);
box-shadow: 0 10px 16px rgba(28, 27, 34, 0.14);
}
19%, 100% {
transform: translateY(0) scale(1);
box-shadow: 0 1px 2px rgba(28, 27, 34, 0.06);
}
}
@media (max-width: 340px), (max-height: 220px) {
.qm {
column-gap: 4px;
width: 100%;
}
.qm__card {
gap: 4px;
padding: 4px;
}
.qm__mark {
display: none;
}
.qm.is-demo .qm__card {
animation-name: qmStepSm;
}
.qm__card:hover,
.qm__card:focus-visible {
transform: translateY(-4px) scale(1.03);
}
.qm__card--1 {
grid-row: span 8;
}
.qm__card--2 {
grid-row: span 10;
}
.qm__card--3 {
grid-row: span 6;
}
.qm__card--4 {
grid-row: span 8;
}
.qm__card--5 {
grid-row: span 6;
}
.qm__t {
font-size: 10px;
line-height: 1.45;
}
.qm__by {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.qm__card {
animation: none !important;
}
}(function () {
var root = document.querySelector('.qm');
var cards = [].slice.call(root.querySelectorAll('.qm__card'));
// 사람이 손을 대면 자동 순번은 멈추고 호버·포커스 확대(transition)만 남는다
function stopLoop() { root.classList.remove('is-demo'); }
cards.forEach(function (card, i) {
card.addEventListener('keydown', function (e) {
var to = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { to = (i + 1) % cards.length; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { to = (i - 1 + cards.length) % cards.length; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = cards.length - 1; }
if (to < 0) { return; }
e.preventDefault();
stopLoop();
cards[to].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./QuoteMasonry.module.scss";
/* ambient 에 React 이벤트 제네릭이 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
export type QuoteItem = {
quote: string;
by: string;
};
export type QuoteMasonryProps = {
sectionLabel?: string;
items?: QuoteItem[];
/** 다섯 장이 차례로 한 번씩 나왔다 들어가는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const QuotesIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M108,72v72H40a8,8,0,0,1-8-8V72a8,8,0,0,1,8-8h60A8,8,0,0,1,108,72Zm108-8H156a8,8,0,0,0-8,8v64a8,8,0,0,0,8,8h68V72A8,8,0,0,0,216,64Z" opacity="0.2" />
<path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z" />
</svg>
);
const DEFAULT_ITEMS: QuoteItem[] = [
{ quote: "후기부터 본다는 말을 손님에게 들었습니다", by: "온라인 서점 운영팀" },
{ quote: "설명이 길지 않은데도 무엇이 좋아졌는지 알겠다는 반응이 많았습니다", by: "동네 빵집 사장" },
{ quote: "재구매가 늘었습니다", by: "수제 가구 공방 대표" },
{ quote: "붙여 넣고 폰트만 바꿨더니 그대로 어울렸습니다", by: "꽃집 운영자" },
{ quote: "장바구니 이탈이 줄었습니다", by: "문구점 점장" },
];
export default function QuoteMasonry({
sectionLabel = "높이 다른 후기 인용 그리드",
items = DEFAULT_ITEMS,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffffff",
}: QuoteMasonryProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const cardRefs = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const tone = [styles.card1, styles.card2, styles.card3, styles.card4, styles.card5];
/* 방향키로 카드 사이를 옮겨 다닌다 (Home·End 는 양 끝) */
const onKey = (e: KeyEvt, i: number): void => {
const list = cardRefs.current;
if (!list) { return; }
const n = list.length;
let to = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") { to = (i + 1) % n; }
if (e.key === "ArrowLeft" || e.key === "ArrowUp") { to = (i - 1 + n) % n; }
if (e.key === "Home") { to = 0; }
if (e.key === "End") { to = n - 1; }
if (to < 0) { return; }
e.preventDefault();
setLoop(false);
const next = list[to];
if (next) { next.focus({ preventScroll: true }); }
};
return (
<section
className={loop ? `${styles.qm} ${styles.isDemo}` : styles.qm}
style={vars}
aria-label={sectionLabel}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
{items.map((it: QuoteItem, i: number) => (
<figure
className={tone[i % tone.length]}
tabIndex={0}
key={it.by}
ref={(el: HTMLElement | null) => { cardRefs.current[i] = el; }}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.mark} aria-hidden="true"><QuotesIcon /></span>
<blockquote className={styles.q}>
<p className={styles.t}>{it.quote}</p>
</blockquote>
<figcaption className={styles.by}>{it.by}</figcaption>
</figure>
))}
</section>
);
}02 높이 다른 인용 그리드 — 측정 누적면적 29.035% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 고객 후기 디자인 패턴 9종, 신뢰를 보여 주는
이 데모가 속한 글
고객 후기 디자인 패턴 9종, 신뢰를 보여 주는고객 후기 디자인은 다른 사람이 남긴 평가를 화면에 얹어, 망설이는 방문자를 마지막으로 안심시키는 섹션입니다. 아홉 가지는 후기 카드·별점·고객사 목록을 HTML 과 CSS 만으로 지어서 옮겨 붙이면 그대로 움직입니 이 글의 데모 전부 보기


