08짧은 글 카드 월
짧은 한마디 카드 석 장이 어두운 바탕에 떠 있고, 한 장은 테두리 빛이 한 바퀴 돌고 다른 한 장은 네온 테가 숨 쉽니다.
쓰임SNS 반응을 모아 보여 주는 어두운 톤 섹션
@propertyconic-gradienttranslateY

<main class="stage">
<section class="pw is-demo" aria-label="짧은 후기 카드 모음">
<div class="pw__wall">
<article class="pw__card pw__card--a">
<header class="pw__top">
<span class="pw__ini" aria-hidden="true">디</span>
<a class="pw__who" href="#">
<span class="pw__name">디자인 스튜디오</span>
<span class="pw__role">브랜드 디자이너</span>
</a>
</header>
<p class="pw__body">버튼 하나 고치는 데 새벽까지 안 걸렸다</p>
<p class="pw__likes" role="img" aria-label="좋아요 128개">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M232,102c0,66-104,122-104,122S24,168,24,102A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32A54,54,0,0,1,232,102Z" opacity="0.2"/><path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z"/></svg><span class="pw__count">128</span>
</p>
</article>
<article class="pw__card pw__card--b">
<header class="pw__top">
<span class="pw__ini" aria-hidden="true">소</span>
<a class="pw__who" href="#">
<span class="pw__name">소규모 개발팀</span>
<span class="pw__role">프런트엔드 담당</span>
</a>
</header>
<p class="pw__body">테두리 한 줄 바꿨더니 문의가 줄었다</p>
<p class="pw__likes" role="img" aria-label="좋아요 86개">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M232,102c0,66-104,122-104,122S24,168,24,102A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32A54,54,0,0,1,232,102Z" opacity="0.2"/><path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z"/></svg><span class="pw__count">86</span>
</p>
</article>
<article class="pw__card pw__card--c">
<header class="pw__top">
<span class="pw__ini" aria-hidden="true">문</span>
<a class="pw__who" href="#">
<span class="pw__name">문구 편집자</span>
<span class="pw__role">콘텐츠 운영</span>
</a>
</header>
<p class="pw__body">설명서 없이 그대로 붙여 썼다</p>
<p class="pw__likes" role="img" aria-label="좋아요 47개">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M232,102c0,66-104,122-104,122S24,168,24,102A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32A54,54,0,0,1,232,102Z" opacity="0.2"/><path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z"/></svg><span class="pw__count">47</span>
</p>
</article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 276 후기·소셜 프루프 팔레트 — 아홉 항목이 같은 값을 쓴다
$pf-ink: #1c1b22;
$pf-slate: #242230; // 잉크 무대 위 카드 면 (크림 글자 14.6:1 · 민트 글자 10.45:1)
$pf-cream: #fbf7ef;
$pf-teal: #479089;
$pf-violet: #a49cce; // 오로라·네온용 — 글자색으로는 안 쓴다 (어두운 면에서 2.55:1)
$pf-mint: #b3dad5;
$duration: $dur-loop; // 세 장이 떠올랐다 앉는 한 바퀴 = 오로라가 한 바퀴 도는 시간
$easing: $ease-spring; // 떠오름의 감속
$color: $stage-ink; // 무대
@include stage($stage-ink);
// 오로라 각도 — inherits:false 로 등록하면 그 요소 자신의 값이어야 한다.
// 그래서 트랙·부모가 아니라 카드 A 자신에 건다
@property --pw-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.pw {
box-sizing: border-box;
width: 100%;
max-width: 432px;
}
.pw__wall {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: $sp-2;
align-items: start;
}
// 어긋난 높이 — 세 장이 서로 다른 높이에서 시작한다
.pw__card {
box-sizing: border-box;
min-width: 0;
display: grid;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-card;
background: $pf-slate;
color: $pf-cream;
}
.pw__card--b { margin-top: $sp-4; }
.pw__card--c { margin-top: $sp-2; }
// 카드 A — 오로라 그라디언트 테두리. 안쪽 면은 padding-box, 도는 빛은 border-box
.pw__card--a {
border: 2px solid transparent;
background:
linear-gradient($pf-slate, $pf-slate) padding-box,
conic-gradient(from var(--pw-angle), $pf-violet, $pf-mint, $pf-teal, $pf-violet) border-box;
}
// 카드 B — 다크 네온 테. 숨 쉬듯 밝기가 오갔다 돌아온다
.pw__card--b {
border: 2px solid rgba($pf-mint, .45);
box-shadow: 0 0 6px rgba($pf-mint, .18), inset 0 0 8px rgba($pf-violet, .18);
}
// 카드 C — 담백한 잉크 카드 (대조군)
.pw__card--c { border: 2px solid rgba($pf-cream, .18); }
.pw__top {
display: flex;
align-items: center;
gap: $sp-1;
min-width: 0;
}
.pw__ini {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba($pf-mint, .18);
color: $pf-mint;
font-weight: 800;
font-size: 11px;
line-height: 1;
}
.pw__who {
min-width: 0;
display: grid;
color: inherit;
text-decoration: none;
}
.pw__who:focus-visible {
outline: 2px solid $pf-mint;
outline-offset: 2px;
border-radius: $r-xs;
}
.pw__name {
color: $pf-cream;
font-weight: 700;
font-size: clamp(9px, 2.3vw, 11px);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pw__role {
color: $pf-mint;
font-weight: 500;
font-size: clamp(8px, 2vw, 10px);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// p·figure 의 기본 margin 은 0 으로 명시한다
.pw__body {
margin: 0;
color: $pf-cream;
font-weight: 500;
font-size: clamp(9px, 2.3vw, 12px);
line-height: 1.4;
}
.pw__likes {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0;
color: $pf-mint;
}
.pw__count {
font-weight: 700;
font-size: clamp(9px, 2.2vw, 11px);
line-height: 1.2;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 세 장이 어긋난 시점에 떠올랐다 앉고, 그 위에서 A 의 오로라가 한 바퀴 돌고
// B 의 네온이 숨 쉰다. animation 단축은 delay·fill-mode 를 리셋하므로 낱개 속성으로 쓴다
.pw.is-demo .pw__card {
animation-name: pwFloat;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pw.is-demo .pw__card--a {
animation-name: pwFloat, pwSpin;
animation-duration: $duration, $duration;
animation-timing-function: $easing, linear;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: 0s, 0s;
}
.pw.is-demo .pw__card--b {
animation-name: pwFloat, pwNeon;
animation-duration: $duration, $duration;
animation-timing-function: $easing, $ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: .16s, 0s;
}
.pw.is-demo .pw__card--c { animation-delay: .32s; }
@keyframes pwFloat {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
66% { transform: translateY(3px); }
}
@keyframes pwSpin {
from { --pw-angle: 0deg; }
to { --pw-angle: 360deg; }
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이다
@keyframes pwNeon {
0%, 88%, 100% {
border-color: rgba($pf-mint, .45);
box-shadow: 0 0 6px rgba($pf-mint, .18), inset 0 0 8px rgba($pf-violet, .18);
}
46% {
border-color: $pf-mint;
box-shadow: 0 0 16px rgba($pf-mint, .6), 0 0 30px rgba($pf-violet, .45), inset 0 0 14px rgba($pf-violet, .38);
}
}
// 좁은 화면 — 세 칸을 유지하고 여백·아바타만 줄인다
@media (max-width: 340px), (max-height: 220px) {
.pw__wall { gap: $sp-1; }
.pw__card { padding: $sp-1; gap: 0; }
.pw__card--b { margin-top: $sp-3; }
.pw__card--c { margin-top: $sp-1; }
.pw__ini { width: 18px; height: 18px; font-size: 9px; }
.pw__likes svg { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .pw.is-demo .pw__card (0,3,0) 를 이기려면 !important 가 필요하다
.pw__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --pw-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.pw {
box-sizing: border-box;
width: 100%;
max-width: 432px;
}
.pw__wall {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
align-items: start;
}
.pw__card {
box-sizing: border-box;
min-width: 0;
display: grid;
gap: 4px;
padding: 8px;
border-radius: 16px;
background: #242230;
color: #fbf7ef;
}
.pw__card--b {
margin-top: 16px;
}
.pw__card--c {
margin-top: 8px;
}
.pw__card--a {
border: 2px solid transparent;
background: linear-gradient(#242230, #242230) padding-box, conic-gradient(from var(--pw-angle), #a49cce, #b3dad5, #479089, #a49cce) border-box;
}
.pw__card--b {
border: 2px solid rgba(179, 218, 213, 0.45);
box-shadow: 0 0 6px rgba(179, 218, 213, 0.18), inset 0 0 8px rgba(164, 156, 206, 0.18);
}
.pw__card--c {
border: 2px solid rgba(251, 247, 239, 0.18);
}
.pw__top {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.pw__ini {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(179, 218, 213, 0.18);
color: #b3dad5;
font-weight: 800;
font-size: 11px;
line-height: 1;
}
.pw__who {
min-width: 0;
display: grid;
color: inherit;
text-decoration: none;
}
.pw__who:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
border-radius: 4px;
}
.pw__name {
color: #fbf7ef;
font-weight: 700;
font-size: clamp(9px, 2.3vw, 11px);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pw__role {
color: #b3dad5;
font-weight: 500;
font-size: clamp(8px, 2vw, 10px);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pw__body {
margin: 0;
color: #fbf7ef;
font-weight: 500;
font-size: clamp(9px, 2.3vw, 12px);
line-height: 1.4;
}
.pw__likes {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
color: #b3dad5;
}
.pw__count {
font-weight: 700;
font-size: clamp(9px, 2.2vw, 11px);
line-height: 1.2;
}
.pw.is-demo .pw__card {
animation-name: pwFloat;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pw.is-demo .pw__card--a {
animation-name: pwFloat, pwSpin;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), linear;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: 0s, 0s;
}
.pw.is-demo .pw__card--b {
animation-name: pwFloat, pwNeon;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: 0.16s, 0s;
}
.pw.is-demo .pw__card--c {
animation-delay: 0.32s;
}
@keyframes pwFloat {
0%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-10px);
}
66% {
transform: translateY(3px);
}
}
@keyframes pwSpin {
from {
--pw-angle: 0deg;
}
to {
--pw-angle: 360deg;
}
}
@keyframes pwNeon {
0%, 88%, 100% {
border-color: rgba(179, 218, 213, 0.45);
box-shadow: 0 0 6px rgba(179, 218, 213, 0.18), inset 0 0 8px rgba(164, 156, 206, 0.18);
}
46% {
border-color: #b3dad5;
box-shadow: 0 0 16px rgba(179, 218, 213, 0.6), 0 0 30px rgba(164, 156, 206, 0.45), inset 0 0 14px rgba(164, 156, 206, 0.38);
}
}
@media (max-width: 340px), (max-height: 220px) {
.pw__wall {
gap: 4px;
}
.pw__card {
padding: 4px;
gap: 0;
}
.pw__card--b {
margin-top: 12px;
}
.pw__card--c {
margin-top: 4px;
}
.pw__ini {
width: 18px;
height: 18px;
font-size: 9px;
}
.pw__likes svg {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pw__card {
animation: none !important;
}
}(function () {
var root = document.querySelector('.pw');
var links = [].slice.call(root.querySelectorAll('.pw__who'));
function stopLoop() { root.classList.remove('is-demo'); }
// 카드 사이를 좌우 화살표로 옮겨 다닌다 (Home·End 는 양 끝)
links.forEach(function (a, i) {
a.addEventListener('keydown', function (e) {
var to = -1;
if (e.key === 'ArrowRight') { to = (i + 1) % links.length; }
if (e.key === 'ArrowLeft') { to = (i - 1 + links.length) % links.length; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = links.length - 1; }
if (to < 0) { return; }
e.preventDefault();
stopLoop();
links[to].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('touchstart', stopLoop, { passive: true });
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./PostCardWall.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
export type PostCard = {
/** 아바타 원에 얹는 이니셜 한 글자 */
initial: string;
/** 일반 명사 이름 */
name: string;
/** 「@」 손잡이 대신 직함 한 줄 */
role: string;
body: string;
likes: number;
/** 하트 수를 읽어 줄 문장 */
likesLabel: string;
};
export type PostCardWallProps = {
label?: string;
cards?: PostCard[];
/** 세 장이 떠올랐다 앉는 한 바퀴 = 오로라가 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HeartIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M232,102c0,66-104,122-104,122S24,168,24,102A54,54,0,0,1,78,48c22.59,0,41.94,12.31,50,32,8.06-19.69,27.41-32,50-32A54,54,0,0,1,232,102Z" opacity="0.2" />
<path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z" />
</svg>
);
const DEFAULT_CARDS: PostCard[] = [
{ initial: "디", name: "디자인 스튜디오", role: "브랜드 디자이너", body: "버튼 하나 고치는 데 새벽까지 안 걸렸다", likes: 128, likesLabel: "좋아요 128개" },
{ initial: "소", name: "소규모 개발팀", role: "프런트엔드 담당", body: "테두리 한 줄 바꿨더니 문의가 줄었다", likes: 86, likesLabel: "좋아요 86개" },
{ initial: "문", name: "문구 편집자", role: "콘텐츠 운영", body: "설명서 없이 그대로 붙여 썼다", likes: 47, likesLabel: "좋아요 47개" },
];
const TONE = [styles.cardA, styles.cardB, styles.cardC];
export default function PostCardWall({
label = "짧은 후기 카드 모음",
cards = DEFAULT_CARDS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: PostCardWallProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const refs = useRef<Array<HTMLAnchorElement | null>>([]) as Box<Array<HTMLAnchorElement | null>>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 카드 사이를 좌우 화살표로 옮겨 다닌다 (Home·End 는 양 끝) */
const onKey = (e: KeyEvt, i: number): void => {
const list = refs.current;
let to = -1;
if (e.key === "ArrowRight") { to = (i + 1) % cards.length; }
if (e.key === "ArrowLeft") { 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();
setLoop(false);
const next = list[to];
if (next) { next.focus({ preventScroll: true }); }
};
return (
<section
className={loop ? `${styles.pw} ${styles.isDemo}` : styles.pw}
style={vars}
aria-label={label}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.wall}>
{cards.map((c: PostCard, i: number) => (
<article className={`${styles.card} ${TONE[i % TONE.length]}`} key={c.name}>
<header className={styles.top}>
<span className={styles.ini} aria-hidden="true">{c.initial}</span>
<a
className={styles.who}
href="#"
ref={(el: HTMLAnchorElement | null) => { refs.current[i] = el; }}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.name}>{c.name}</span>
<span className={styles.role}>{c.role}</span>
</a>
</header>
<p className={styles.body}>{c.body}</p>
<p className={styles.likes} role="img" aria-label={c.likesLabel}>
<HeartIcon />
<span className={styles.count}>{c.likes}</span>
</p>
</article>
))}
</div>
</section>
);
}08 짧은 글 카드 월 — 측정 누적면적 26.323% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 고객 후기 디자인 패턴 9종, 신뢰를 보여 주는
이 데모가 속한 글
고객 후기 디자인 패턴 9종, 신뢰를 보여 주는고객 후기 디자인은 다른 사람이 남긴 평가를 화면에 얹어, 망설이는 방문자를 마지막으로 안심시키는 섹션입니다. 아홉 가지는 후기 카드·별점·고객사 목록을 HTML 과 CSS 만으로 지어서 옮겨 붙이면 그대로 움직입니 이 글의 데모 전부 보기


