프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음
프로필 카드 디자인은 아바타·이름·직함·소셜 스탯·팔로우 버튼이라는 한 덩어리를 어떤 재질로 보여줄지 정하는 일입니다. 아홉 개는 그 안쪽 구조가 전부 같고, 바깥 재질과 움직임만 다릅니다.
01 profile-card-glass-frost
02 profile-card-neo-brutal-offset
03 profile-card-clay-puffed
04 profile-card-minimal-line
05 profile-card-gradient-border-spin
06 profile-card-dark-glow-ring
07 profile-card-flip-contact
08 profile-card-stat-countup
09 profile-card-team-grid-expand
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 글래스모피즘
- 02 네오브루탈리즘 오프셋
- 03 클레이모피즘
- 04 미니멀 라인
- 05 회전하는 그라디언트 테두리
- 06 다크 글로우 링
- 07 3D 플립 연락처
- 08 소셜 스탯 카운트업
- 09 팀 카드 그리드 확장
앞의 여섯은 카드 한 장의 표면만 바꿉니다. 마크업도 글자도 버튼도 같고 재질만 달라지므로, 하나를 골라 붙인 뒤 스타일 블록만 갈아 끼우면 다른 다섯 개가 됩니다. 순서는 빛을 얼마나 다루느냐입니다. 뒤에 있는 것을 흐리게 비추는 유리(01)에서 시작해, 빛 대신 그림자를 도형처럼 쓰는 네오브루탈리즘(02), 안팎 그림자를 겹쳐 부피를 만드는 점토(03), 그림자를 전부 버리고 선만 남긴 미니멀(04), 테두리만 빛나는 그라디언트(05), 어둠 속에서 번지는 네온(06)으로 갑니다. 뒤의 셋은 표면이 아니라 구조를 하나씩 더 갖습니다 — 뒷면(07), 시간(08), 이웃(09). 복붙해서 쓰는 프로필 카드 양식이지만, 아홉 개 전부 팔로우 버튼이 실제로 aria-pressed 를 바꾸고 글자와 아이콘을 갈아 끼웁니다. 카드가 아니라 섹션 단위로 얼굴을 올리려면 믿음 가는 랜딩 페이지 섹션 템플릿이 맞고, 카드 안에 가격이 들어가는 순간에는 요금제 디자인 인터랙션이 이어받습니다.
01글래스모피즘
프로필 카드 ui 는 무엇이 보이나
카드 뒤에서 색 얼룩 두 개가 천천히 흘러가고, 반투명한 카드가 그 색을 흐리게 비춥니다.

<main class="stage">
<div class="gf is-demo">
<div class="gf__blob gf__blob--a" aria-hidden="true"></div>
<div class="gf__blob gf__blob--b" aria-hidden="true"></div>
<article class="gf__card">
<div class="gf__head">
<div class="gf__avatar">SR</div>
<div class="gf__who">
<p class="gf__name">이서린</p>
<p class="gf__role">프로덕트 디자이너</p>
</div>
</div>
<div class="gf__stats">
<div class="gf__stat"><strong class="gf__num">2,431</strong><span class="gf__lab">팔로워</span></div>
<div class="gf__stat"><strong class="gf__num">128</strong><span class="gf__lab">게시물</span></div>
</div>
<button class="gf__btn" type="button" id="gf-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="gf-label">팔로우</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
// 글래스모피즘 — 반투명 카드가 뒤에서 떠다니는 색 얼룩 두 개를 흐리게 비춘다.
.gf {
position: relative;
width: 300px;
height: 192px;
overflow: hidden; // 얼룩이 카드 바깥으로 새지 않게 (고정 크기 + clip)
border-radius: $r-card;
user-select: none;
-webkit-user-select: none;
}
.gf__blob { position: absolute; border-radius: 50%; pointer-events: none; }
.gf__blob--a {
top: -24px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle at 38% 38%, $subject-pink, rgba($subject-pink, 0) 72%);
}
.gf__blob--b {
top: 50px; left: 160px;
width: 150px; height: 150px;
background: radial-gradient(circle at 62% 62%, $subject-lilac, rgba($subject-lilac, 0) 72%);
}
.gf__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
padding: 0 $sp-4;
background: rgba(255, 255, 255, .30);
border: 1px solid rgba(255, 255, 255, .62);
border-radius: $r-card;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: $subject-ink;
}
.gf__head { display: flex; align-items: center; gap: $sp-3; }
.gf__avatar {
display: flex; align-items: center; justify-content: center;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-cream, $subject-pink);
border: 1px solid rgba(255, 255, 255, .55);
color: $subject-ink;
font-size: 18px; line-height: 22px; font-weight: 800;
}
.gf__who { display: flex; flex-direction: column; gap: $sp-1; }
.gf__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; color: $subject-ink; }
.gf__role { margin: 0; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba(23, 20, 26, .74); }
.gf__stats { display: flex; align-items: center; gap: $sp-4; }
.gf__stat { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.gf__stat + .gf__stat { border-left: 1px solid rgba(23, 20, 26, .22); padding-left: $sp-4; }
.gf__num { font-size: 17px; line-height: 20px; font-weight: 800; color: $subject-ink; font-variant-numeric: tabular-nums; }
.gf__lab { font-size: 11px; line-height: 13px; font-weight: 600; color: rgba(23, 20, 26, .74); }
.gf__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-4;
background: rgba(255, 255, 255, .58);
border: 1px solid rgba(255, 255, 255, .82);
border-radius: $r-pill;
color: $subject-ink;
font-size: 13px; line-height: 16px; font-weight: 700;
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
}
.gf__btn svg { display: block; }
.gf__btn svg[hidden] { display: none; }
.gf__btn:hover { background: rgba(255, 255, 255, .72); }
.gf__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gf__btn[aria-pressed="true"] { background: $subject-ink; border-color: rgba(23, 20, 26, .55); color: $subject-cream; }
// ── 자동 루프: 얼룩 두 개가 서로 다른 경로로 카드 뒤를 천천히 지난다. 끝 프레임 = 첫 프레임 ──
.gf.is-demo .gf__blob--a { animation: gf-drift-a $dur-loop linear infinite; }
.gf.is-demo .gf__blob--b { animation: gf-drift-b $dur-loop linear infinite; }
@keyframes gf-drift-a {
0%, 100% { transform: translate(0, 0); }
36% { transform: translate(84px, 46px); }
70% { transform: translate(148px, 8px); }
}
@keyframes gf-drift-b {
0%, 100% { transform: translate(0, 0); }
40% { transform: translate(-64px, -38px); }
76% { transform: translate(-132px, -10px); }
}
@media (max-width: 340px) {
.gf { width: 272px; height: 146px; }
.gf__card { gap: $sp-1; padding: 0 $sp-3; }
.gf__head { gap: $sp-2; }
.gf__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 18px; }
.gf__who { gap: 0; }
.gf__name { font-size: 14px; line-height: 18px; }
.gf__role { font-size: 11px; line-height: 13px; }
.gf__stats { gap: $sp-3; }
.gf__stat { gap: 0; }
.gf__stat + .gf__stat { padding-left: $sp-3; }
.gf__num { font-size: 13px; line-height: 16px; }
.gf__lab { font-size: 11px; line-height: 13px; }
.gf__btn { height: 32px; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
.gf__blob--a { width: 104px; height: 104px; }
.gf__blob--b { width: 116px; height: 116px; top: 32px; left: 148px; }
}
@media (prefers-reduced-motion: reduce) {
.gf.is-demo .gf__blob--a,
.gf.is-demo .gf__blob--b { animation: none !important; }
.gf__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gf {
position: relative;
width: 300px;
height: 192px;
overflow: hidden;
border-radius: 16px;
user-select: none;
-webkit-user-select: none;
}
.gf__blob {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.gf__blob--a {
top: -24px;
left: -30px;
width: 140px;
height: 140px;
background: radial-gradient(circle at 38% 38%, #ff9ebb, rgba(255, 158, 187, 0) 72%);
}
.gf__blob--b {
top: 50px;
left: 160px;
width: 150px;
height: 150px;
background: radial-gradient(circle at 62% 62%, #b9a0ff, rgba(185, 160, 255, 0) 72%);
}
.gf__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 16px;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.62);
border-radius: 16px;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: #17141a;
}
.gf__head {
display: flex;
align-items: center;
gap: 12px;
}
.gf__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #fff7e6, #ff9ebb);
border: 1px solid rgba(255, 255, 255, 0.55);
color: #17141a;
font-size: 18px;
line-height: 22px;
font-weight: 800;
}
.gf__who {
display: flex;
flex-direction: column;
gap: 4px;
}
.gf__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
color: #17141a;
}
.gf__role {
margin: 0;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(23, 20, 26, 0.74);
}
.gf__stats {
display: flex;
align-items: center;
gap: 16px;
}
.gf__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.gf__stat + .gf__stat {
border-left: 1px solid rgba(23, 20, 26, 0.22);
padding-left: 16px;
}
.gf__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.gf__lab {
font-size: 11px;
line-height: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.74);
}
.gf__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 16px;
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 999px;
color: #17141a;
font-size: 13px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
transition: background 220ms ease-out, color 220ms ease-out;
}
.gf__btn svg {
display: block;
}
.gf__btn svg[hidden] {
display: none;
}
.gf__btn:hover {
background: rgba(255, 255, 255, 0.72);
}
.gf__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.gf__btn[aria-pressed=true] {
background: #17141a;
border-color: rgba(23, 20, 26, 0.55);
color: #fff7e6;
}
.gf.is-demo .gf__blob--a {
animation: gf-drift-a 2s linear infinite;
}
.gf.is-demo .gf__blob--b {
animation: gf-drift-b 2s linear infinite;
}
@keyframes gf-drift-a {
0%, 100% {
transform: translate(0, 0);
}
36% {
transform: translate(84px, 46px);
}
70% {
transform: translate(148px, 8px);
}
}
@keyframes gf-drift-b {
0%, 100% {
transform: translate(0, 0);
}
40% {
transform: translate(-64px, -38px);
}
76% {
transform: translate(-132px, -10px);
}
}
@media (max-width: 340px) {
.gf {
width: 272px;
height: 146px;
}
.gf__card {
gap: 4px;
padding: 0 12px;
}
.gf__head {
gap: 8px;
}
.gf__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 18px;
}
.gf__who {
gap: 0;
}
.gf__name {
font-size: 14px;
line-height: 18px;
}
.gf__role {
font-size: 11px;
line-height: 13px;
}
.gf__stats {
gap: 12px;
}
.gf__stat {
gap: 0;
}
.gf__stat + .gf__stat {
padding-left: 12px;
}
.gf__num {
font-size: 13px;
line-height: 16px;
}
.gf__lab {
font-size: 11px;
line-height: 13px;
}
.gf__btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
.gf__blob--a {
width: 104px;
height: 104px;
}
.gf__blob--b {
width: 116px;
height: 116px;
top: 32px;
left: 148px;
}
}
@media (prefers-reduced-motion: reduce) {
.gf.is-demo .gf__blob--a,
.gf.is-demo .gf__blob--b {
animation: none !important;
}
.gf__btn {
transition: none;
}
}// 실제 동작: 팔로우 버튼 토글 — 아이콘 두 개를 hidden 으로 교체한다. 첫 조작에 자동 루프가 끊긴다.
var root = document.querySelector('.gf');
var btn = document.getElementById('gf-btn');
var label = document.getElementById('gf-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? '팔로잉' : '팔로우';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./GlassFrostCard.module.scss";
export type GlassFrostCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconFollow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function GlassFrostCard({
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: GlassFrostCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<div className={styles.gf}>
<div className={styles.blobA} aria-hidden="true" />
<div className={styles.blobB} aria-hidden="true" />
<article className={styles.card}>
<div className={styles.head}>
<div className={styles.avatar}>SR</div>
<div className={styles.who}>
<p className={styles.name}>이서린</p>
<p className={styles.role}>프로덕트 디자이너</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>2,431</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>128</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconFollow />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}01 · 글래스모피즘 — 측정 누적면적 33.061% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
서리 낀 유리를 만드는 것은 backdrop-filter 한 줄이지만, 그 뒤에 실제로 흐려질 것이 있어야 효과가 보입니다.
.gf__card {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .30);
border: 1px solid rgba(255, 255, 255, .62);
border-radius: $r-card;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: $subject-ink;
}
.gf__blob--a {
top: -24px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle at 38% 38%, $subject-pink, rgba($subject-pink, 0) 72%);
}
02네오브루탈리즘 오프셋
화면에서 일어나는 일
굵은 테두리와 오른쪽 아래로 어긋난 단색 그림자가 카드를 오려 붙인 종이처럼 띄웁니다.

<main class="stage">
<div class="nb is-demo">
<article class="nb__card">
<div class="nb__head">
<div class="nb__avatar">DH</div>
<div class="nb__who">
<p class="nb__name">박도현</p>
<p class="nb__role">프론트엔드 개발자</p>
</div>
</div>
<div class="nb__stats">
<div class="nb__stat"><strong class="nb__num">1,842</strong><span class="nb__lab">팔로워</span></div>
<div class="nb__stat"><strong class="nb__num">36</strong><span class="nb__lab">게시물</span></div>
</div>
<button class="nb__btn" type="button" id="nb-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="nb-label">팔로우</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 네오브루탈리즘 — 3px 잉크 테두리 + 오프셋 단색 그림자. 누르면 그림자 자리로 내려앉는다.
.nb {
position: relative;
width: 308px; // 카드 300 + 그림자 8 (눌림 여유)
height: 204px; // 카드 196 + 8
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
width: 300px;
height: 196px;
padding: 0 $sp-4;
background: #fff;
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
color: $subject-ink;
}
.nb__head { display: flex; align-items: center; gap: $sp-3; }
.nb__avatar {
display: flex; align-items: center; justify-content: center;
box-sizing: border-box;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-sky, $subject-blue);
border: 3px solid $subject-ink;
color: $subject-ink;
font-size: 17px; line-height: 21px; font-weight: 800;
}
.nb__who { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-1; }
.nb__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; }
.nb__role {
margin: 0;
display: inline-flex; align-items: center;
padding: $sp-1 $sp-2;
background: $color;
border-radius: $r-pill;
color: #fff;
font-size: 11px; line-height: 13px; font-weight: 700;
}
.nb__stats { display: flex; gap: $sp-2; }
.nb__stat {
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2;
background: $stage-yellow;
border: 3px solid $subject-ink;
border-radius: $r-sm;
}
.nb__num { font-size: 17px; line-height: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nb__lab { font-size: 11px; line-height: 13px; font-weight: 700; }
.nb__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-4;
background: $subject-ink;
border: 3px solid $subject-ink;
border-radius: $r-control;
box-shadow: 4px 4px 0 $subject-ink;
color: #fff;
font-size: 13px; line-height: 16px; font-weight: 700;
cursor: pointer;
transition: transform $duration $easing, box-shadow $duration $easing, background $duration $easing;
}
.nb__btn svg { display: block; }
.nb__btn svg[hidden] { display: none; }
.nb__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nb__btn[aria-pressed="true"] { background: $color; }
.nb__card:active { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
.nb__btn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 $subject-ink; }
// ── 자동 루프: 카드가 그림자 자리로 내려앉았다 올라온다(두 번). 복귀는 68% 안에 끝 — 끝 프레임 = 첫 프레임 ──
.nb.is-demo .nb__card { animation: nb-press $dur-loop $ease-out infinite; }
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
@media (max-width: 340px) {
.nb { width: 280px; height: 158px; }
.nb__card { width: 272px; height: 150px; gap: $sp-1; padding: 0 $sp-3; }
.nb__head { gap: $sp-2; }
.nb__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 17px; }
.nb__who { gap: 0; }
.nb__name { font-size: 14px; line-height: 18px; }
.nb__stats { gap: $sp-1; }
.nb__stat { gap: 0; padding: $sp-1; }
.nb__num { font-size: 13px; line-height: 16px; }
.nb__lab { font-size: 11px; line-height: 13px; }
.nb__btn { height: 32px; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card { animation: none !important; }
.nb__card, .nb__btn { transition: none; }
}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);
}
.nb {
position: relative;
width: 308px;
height: 204px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 300px;
height: 196px;
padding: 0 16px;
background: #fff;
border: 3px solid #17141a;
border-radius: 16px;
box-shadow: 8px 8px 0 #17141a;
color: #17141a;
}
.nb__head {
display: flex;
align-items: center;
gap: 12px;
}
.nb__avatar {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #6ec8ff, #2f6df6);
border: 3px solid #17141a;
color: #17141a;
font-size: 17px;
line-height: 21px;
font-weight: 800;
}
.nb__who {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.nb__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
}
.nb__role {
margin: 0;
display: inline-flex;
align-items: center;
padding: 4px 8px;
background: #2f6df6;
border-radius: 999px;
color: #fff;
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__stats {
display: flex;
gap: 8px;
}
.nb__stat {
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
background: #ffd23f;
border: 3px solid #17141a;
border-radius: 8px;
}
.nb__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 16px;
background: #17141a;
border: 3px solid #17141a;
border-radius: 12px;
box-shadow: 4px 4px 0 #17141a;
color: #fff;
font-size: 13px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms ease-out, box-shadow 220ms ease-out, background 220ms ease-out;
}
.nb__btn svg {
display: block;
}
.nb__btn svg[hidden] {
display: none;
}
.nb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nb__btn[aria-pressed=true] {
background: #2f6df6;
}
.nb__card:active {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
.nb__btn:active {
transform: translate(4px, 4px);
box-shadow: 0 0 0 #17141a;
}
.nb.is-demo .nb__card {
animation: nb-press 2s ease-out infinite;
}
@keyframes nb-press {
0%, 100% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
9% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
20% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
46% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
55% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
68% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
}
@media (max-width: 340px) {
.nb {
width: 280px;
height: 158px;
}
.nb__card {
width: 272px;
height: 150px;
gap: 4px;
padding: 0 12px;
}
.nb__head {
gap: 8px;
}
.nb__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 17px;
}
.nb__who {
gap: 0;
}
.nb__name {
font-size: 14px;
line-height: 18px;
}
.nb__stats {
gap: 4px;
}
.nb__stat {
gap: 0;
padding: 4px;
}
.nb__num {
font-size: 13px;
line-height: 16px;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
}
.nb__btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card {
animation: none !important;
}
.nb__card, .nb__btn {
transition: none;
}
}// 실제 동작: 팔로우 버튼 토글. 첫 조작에 자동 루프가 끊기고 :active 눌림이 그대로 살아 있다.
var root = document.querySelector('.nb');
var btn = document.getElementById('nb-btn');
var label = document.getElementById('nb-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? '팔로잉' : '팔로우';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./NeoBrutalCard.module.scss";
export type NeoBrutalCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconFollow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function NeoBrutalCard({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: NeoBrutalCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<div className={styles.nb}>
<article className={styles.card}>
<div className={styles.head}>
<div className={styles.avatar}>DH</div>
<div className={styles.who}>
<p className={styles.name}>박도현</p>
<p className={styles.role}>프론트엔드 개발자</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>1,842</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>36</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconFollow />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}02 · 네오브루탈리즘 오프셋 — 측정 누적면적 18.2% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
누르면 카드가 그림자 자리로 정확히 내려앉고 그림자는 사라집니다.
.nb__card {
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
}
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
03클레이모피즘
어떤 움직임인가
그림자를 세 겹 겹치면 카드가 부풀어 오른 점토처럼 보입니다.

<main class="stage">
<article class="cp is-demo">
<div class="cp__head">
<div class="cp__avatar">HY</div>
<div class="cp__who">
<p class="cp__name">정하윤</p>
<p class="cp__role">일러스트레이터</p>
</div>
</div>
<div class="cp__stats">
<div class="cp__stat"><strong class="cp__num">5,108</strong><span class="cp__lab">팔로워</span></div>
<div class="cp__stat"><strong class="cp__num">94</strong><span class="cp__lab">게시물</span></div>
</div>
<button class="cp__btn" type="button" id="cp-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="cp-label">팔로우</span>
</button>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-lilac;
@include stage($stage-paper);
// 클레이모피즘 — 파스텔 바탕 + 겹친 인셋 그림자로 부풀린 카드. 아바타가 숨 쉰다.
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: 300px;
padding: $sp-4;
border-radius: $r-card;
background: rgba($subject-pink, .28);
box-shadow:
0 18px 30px rgba(23, 20, 26, .14),
inset 6px 6px 12px rgba(255, 255, 255, .9),
inset -6px -6px 12px rgba(23, 20, 26, .10);
color: $subject-ink;
user-select: none;
-webkit-user-select: none;
}
.cp__head { display: flex; align-items: center; gap: $sp-3; }
.cp__avatar {
display: flex; align-items: center; justify-content: center;
width: 56px; height: 56px;
border-radius: 50%;
background: $color;
box-shadow:
inset 4px 4px 10px rgba(255, 255, 255, .85),
inset -4px -4px 10px rgba(23, 20, 26, .12);
color: $subject-ink;
font-size: 18px; line-height: 22px; font-weight: 800;
}
.cp__who { display: flex; flex-direction: column; gap: $sp-1; }
.cp__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; }
.cp__role { margin: 0; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba(23, 20, 26, .62); }
.cp__stats { display: flex; align-items: center; gap: $sp-4; }
.cp__stat { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.cp__stat + .cp__stat { border-left: 1px solid rgba(23, 20, 26, .14); padding-left: $sp-4; }
.cp__num { font-size: 17px; line-height: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.cp__lab { font-size: 11px; line-height: 13px; font-weight: 600; color: rgba(23, 20, 26, .62); }
.cp__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-pill;
background: rgba($subject-lilac, .6);
box-shadow:
0 8px 16px rgba(23, 20, 26, .14),
inset 3px 3px 8px rgba(255, 255, 255, .85),
inset -3px -3px 8px rgba(23, 20, 26, .10);
color: $subject-ink;
font-size: 13px; line-height: 16px; font-weight: 800;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.cp__btn svg { display: block; }
.cp__btn svg[hidden] { display: none; }
.cp__btn:hover { background: rgba($subject-lilac, .78); }
.cp__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cp__btn:active { transform: scale(.95); }
.cp__btn[aria-pressed="true"] { background: rgba($subject-mint, .55); }
// ── 자동 루프: 아바타가 숨 쉬듯 부풀고(복귀 88% 안), 버튼은 시차를 두고 살짝 부풀린다 ──
.cp.is-demo .cp__avatar {
animation-name: cp-breathe;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cp.is-demo .cp__btn {
animation-name: cp-puff;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: -600ms; // 시차 — 단축 속성은 delay 를 리셋하므로 낱개 속성으로 쓴다
}
@keyframes cp-breathe {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.12); }
88% { transform: scale(1); }
}
@keyframes cp-puff {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.07); }
88% { transform: scale(1); }
}
@media (max-width: 340px) {
.cp { width: 272px; gap: $sp-1; padding: $sp-3; }
.cp__head { gap: $sp-2; }
.cp__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 18px; }
.cp__who { gap: 0; }
.cp__name { font-size: 14px; line-height: 18px; }
.cp__role { font-size: 11px; line-height: 13px; }
.cp__stats { gap: $sp-3; }
.cp__stat { gap: 0; }
.cp__stat + .cp__stat { padding-left: $sp-3; }
.cp__num { font-size: 13px; line-height: 16px; }
.cp__lab { font-size: 11px; line-height: 13px; }
.cp__btn { height: 32px; padding: 0 $sp-4; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__avatar,
.cp.is-demo .cp__btn { animation: none !important; }
.cp__btn { transition: none; }
}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);
}
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 300px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 158, 187, 0.28);
box-shadow: 0 18px 30px rgba(23, 20, 26, 0.14), inset 6px 6px 12px rgba(255, 255, 255, 0.9), inset -6px -6px 12px rgba(23, 20, 26, 0.1);
color: #17141a;
user-select: none;
-webkit-user-select: none;
}
.cp__head {
display: flex;
align-items: center;
gap: 12px;
}
.cp__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: #b9a0ff;
box-shadow: inset 4px 4px 10px rgba(255, 255, 255, 0.85), inset -4px -4px 10px rgba(23, 20, 26, 0.12);
color: #17141a;
font-size: 18px;
line-height: 22px;
font-weight: 800;
}
.cp__who {
display: flex;
flex-direction: column;
gap: 4px;
}
.cp__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
}
.cp__role {
margin: 0;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.cp__stats {
display: flex;
align-items: center;
gap: 16px;
}
.cp__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cp__stat + .cp__stat {
border-left: 1px solid rgba(23, 20, 26, 0.14);
padding-left: 16px;
}
.cp__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.cp__lab {
font-size: 11px;
line-height: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.cp__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: rgba(185, 160, 255, 0.6);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.14), inset 3px 3px 8px rgba(255, 255, 255, 0.85), inset -3px -3px 8px rgba(23, 20, 26, 0.1);
color: #17141a;
font-size: 13px;
line-height: 16px;
font-weight: 800;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__btn svg {
display: block;
}
.cp__btn svg[hidden] {
display: none;
}
.cp__btn:hover {
background: rgba(185, 160, 255, 0.78);
}
.cp__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cp__btn:active {
transform: scale(0.95);
}
.cp__btn[aria-pressed=true] {
background: rgba(76, 212, 166, 0.55);
}
.cp.is-demo .cp__avatar {
animation-name: cp-breathe;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__btn {
animation-name: cp-puff;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: -600ms;
}
@keyframes cp-breathe {
0%, 100% {
transform: scale(1);
}
44% {
transform: scale(1.12);
}
88% {
transform: scale(1);
}
}
@keyframes cp-puff {
0%, 100% {
transform: scale(1);
}
44% {
transform: scale(1.07);
}
88% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cp {
width: 272px;
gap: 4px;
padding: 12px;
}
.cp__head {
gap: 8px;
}
.cp__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 18px;
}
.cp__who {
gap: 0;
}
.cp__name {
font-size: 14px;
line-height: 18px;
}
.cp__role {
font-size: 11px;
line-height: 13px;
}
.cp__stats {
gap: 12px;
}
.cp__stat {
gap: 0;
}
.cp__stat + .cp__stat {
padding-left: 12px;
}
.cp__num {
font-size: 13px;
line-height: 16px;
}
.cp__lab {
font-size: 11px;
line-height: 13px;
}
.cp__btn {
height: 32px;
padding: 0 16px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__avatar,
.cp.is-demo .cp__btn {
animation: none !important;
}
.cp__btn {
transition: none;
}
}// 실제 동작: 팔로우 버튼 토글. 첫 조작에 자동 루프가 끊긴다.
var root = document.querySelector('.cp');
var btn = document.getElementById('cp-btn');
var label = document.getElementById('cp-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? '팔로잉' : '팔로우';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ClayPuffCard.module.scss";
export type ClayPuffCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconStar = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function ClayPuffCard({
duration = 300,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#b9a0ff",
}: ClayPuffCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<article className={styles.cp}>
<div className={styles.head}>
<div className={styles.avatar}>HY</div>
<div className={styles.who}>
<p className={styles.name}>정하윤</p>
<p className={styles.role}>일러스트레이터</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>5,108</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>94</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconStar />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
);
}03 · 클레이모피즘 — 측정 누적면적 1.766% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
바깥으로 퍼지는 그림자 하나에 안쪽의 밝은 하이라이트와 어두운 그늘을 더한 조합이 말랑한 부피를 만듭니다.
.cp__card {
border-radius: $r-card;
background: rgba($subject-pink, .28);
box-shadow:
0 18px 30px rgba(23, 20, 26, .14),
inset 6px 6px 12px rgba(255, 255, 255, .9),
inset -6px -6px 12px rgba(23, 20, 26, .10);
}
@keyframes cp-breathe {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.12); }
88% { transform: scale(1); }
}
04미니멀 라인
무엇이 보이나
흰 배경 위의 흰 카드라 배경과 대비가 없습니다.

<main class="stage">
<div class="ml is-demo">
<article class="ml__card">
<div class="ml__avatar" aria-hidden="true">YW</div>
<h2 class="ml__name">최연우<span class="ml__line ml__line--name" aria-hidden="true"></span></h2>
<p class="ml__role">브랜드 전략가</p>
<p class="ml__stats">
<span class="ml__stat"><strong>812</strong>팔로워</span>
<span class="ml__stat"><strong>41</strong>게시물</span>
</p>
<button class="ml__follow" type="button" id="ml-follow" aria-pressed="false">
<svg class="ml__i ml__i--plus" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg class="ml__i ml__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="ml__label" id="ml-label">팔로우</span>
<span class="ml__line ml__line--btn" aria-hidden="true"></span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
// 흰 무대 위 흰 카드 — 1px 선과 여백만으로 형태를 만든다
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: $sp-4;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-card;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: 50%;
background: rgba($subject-blue, .08);
color: $subject-ink;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-ink;
text-align: center;
}
.ml__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
// 밑줄 — 왼쪽부터 scaleX 로 그어지고, 왼쪽으로 접혀 돌아간다 (origin 고정)
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 left에서 그어짐
transition: transform $duration $easing;
}
.ml__line--name { width: 56px; margin: $sp-1 auto 0; }
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: $color;
border-bottom-left-radius: $r-control;
border-bottom-right-radius: $r-control;
}
.ml__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
.ml__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: none;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-control;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color $dur-quick $easing;
}
.ml__follow:hover { border-color: rgba(23, 20, 26, .3); }
.ml__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ml__i { display: inline-flex; }
.ml__i--check { display: none; color: $color; }
.ml__follow[aria-pressed="true"] .ml__i--plus { display: none; }
.ml__follow[aria-pressed="true"] .ml__i--check { display: inline-flex; }
// 실제 상호작용: 호버·키보드 포커스·팔로잉 상태에서 같은 밑줄이 그어진다
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
// ── 자동 루프: 이름 밑줄 → 버튼 아래 테두리(167ms 시차). 복귀는 88% 안 ──
// poster 는 "직전 프레임 대비 가장 크게 변한 프레임" 이라 등속·ease-out 으로 그으면
// 시작 직후의 짧은 작대기가 대표 이미지로 뽑힌다 (실측 2026-09-12). 그래서 마지막 한 칸을
// 한 샘플 간격(4.1667% = 83.3ms) 안에 몰아 넣어, 선이 끝까지 그어지는 프레임이 최대 변화가 되게 한다.
// 버튼 선의 시차 167ms 는 샘플 간격의 정확히 2배 — 컷이 프레임 사이에 딱 떨어진다.
// 되돌아오기는 linear — 한 프레임에 몰리면 그 프레임이 poster 를 뺏는다.
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% { transform: scaleX(0); }
25% { transform: scaleX(.7); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes ml-draw-btn {
0% { transform: scaleX(0); }
25% { transform: scaleX(.4); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@media (max-width: 340px) {
.ml__card { width: 272px; padding: $sp-3; }
.ml__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 16px; }
.ml__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.ml__line--name { width: 44px; margin-top: 0; }
.ml__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.ml__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.ml__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn { animation: none !important; }
.ml__line { transition: none; }
}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);
}
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: 16px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
color: #17141a;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.ml__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__line {
display: block;
height: 2px;
background: #17141a;
transform: scaleX(0);
transform-origin: left center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__line--name {
width: 56px;
margin: 4px auto 0;
}
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: #2f6df6;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.ml__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__stat strong {
margin-right: 4px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: none;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__follow:hover {
border-color: rgba(23, 20, 26, 0.3);
}
.ml__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ml__i {
display: inline-flex;
}
.ml__i--check {
display: none;
color: #2f6df6;
}
.ml__follow[aria-pressed=true] .ml__i--plus {
display: none;
}
.ml__follow[aria-pressed=true] .ml__i--check {
display: inline-flex;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed=true] .ml__line--btn {
transform: scaleX(1);
}
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.7);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes ml-draw-btn {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.4);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@media (max-width: 340px) {
.ml__card {
width: 272px;
padding: 12px;
}
.ml__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 16px;
}
.ml__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.ml__line--name {
width: 44px;
margin-top: 0;
}
.ml__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.ml__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.ml__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn {
animation: none !important;
}
.ml__line {
transition: none;
}
}// 실제 동작: 팔로우 토글(aria-pressed) · 첫 조작에 자동 루프 종료
var root = document.querySelector('.ml');
var btn = document.getElementById('ml-follow');
var label = document.getElementById('ml-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? '팔로우' : '팔로잉';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./MinimalLineCard.module.scss";
export type MinimalLineCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function MinimalLineCard({
name = "최연우",
role = "브랜드 전략가",
initials = "YW",
followers = "812",
posts = "41",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MinimalLineCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.ml} style={vars}>
<article className={styles.card}>
<div className={styles.avatar} aria-hidden="true">{initials}</div>
<h2 className={styles.name}>
{name}
<span className={styles.nameLine} aria-hidden="true" />
</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconUserPlus />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
<span className={styles.btnLine} aria-hidden="true" />
</button>
</article>
</div>
);
}04 · 미니멀 라인 — 측정 누적면적 0.215% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
1px 선과 여백만으로 형태를 잡고, 왼쪽부터 그어지는 이름 밑줄 하나가 유일한 장식입니다.
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center;
transition: transform $duration $easing;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
05회전하는 그라디언트 테두리
화면에서 일어나는 일
원뿔 그라디언트가 카드 둘레를 한 바퀴 돌고, 안쪽 판이 그 위를 덮어 4px 테두리만 빛으로 남습니다.

<main class="stage">
<div class="gr is-demo">
<div class="gr__rim">
<article class="gr__face">
<div class="gr__avatar" aria-hidden="true">JW</div>
<h2 class="gr__name">한지운</h2>
<p class="gr__role">모션 디자이너</p>
<p class="gr__stats">
<span class="gr__stat"><strong>9,640</strong>팔로워</span>
<span class="gr__stat"><strong>210</strong>게시물</span>
</p>
<button class="gr__follow" type="button" id="gr-follow" aria-pressed="false">
<svg class="gr__i gr__i--star" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<svg class="gr__i gr__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="gr__label" id="gr-label">팔로우</span>
</button>
</article>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 회전 각도 — @property 로 등록해야 --angle 이 보간된다 (등록 없으면 테두리가 안 돈다)
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.gr {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
// 바깥 상자: conic 그라디언트 테두리가 2초에 한 바퀴 돈다 (360deg 순환 — 텔레포트 없음)
.gr__rim {
width: 300px;
padding: $sp-1;
border-radius: $r-card;
background: conic-gradient(from var(--angle),
$subject-blue, $subject-pink, $subject-mint, $subject-lilac, $subject-blue);
animation-name: gr-spin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin { to { --angle: 360deg; } }
.gr__face {
display: flex;
flex-direction: column;
align-items: center;
padding: $sp-4;
border-radius: $r-control;
background-color: $stage-ink;
background-image: linear-gradient(rgba(255, 255, 255, .06), rgba(255, 255, 255, .06));
}
.gr__avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-pink);
color: $subject-ink;
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.gr__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
}
.gr__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.gr__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.gr__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.gr__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: none;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: $r-control;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: background $dur-quick $easing, border-color $duration $easing;
}
.gr__follow:hover { background: rgba(255, 255, 255, .08); }
.gr__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gr__follow[aria-pressed="true"] { border-color: rgba($subject-mint, .65); }
.gr__i { display: inline-flex; }
.gr__i--check { display: none; color: $subject-mint; }
.gr__follow[aria-pressed="true"] .gr__i--star { display: none; }
.gr__follow[aria-pressed="true"] .gr__i--check { display: inline-flex; }
@media (max-width: 340px) {
.gr__rim { width: 272px; }
.gr__face { padding: $sp-3; }
.gr__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 16px; }
.gr__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.gr__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.gr__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.gr__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.gr__rim { animation: none !important; }
.gr__follow { transition: none; }
}@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
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);
}
.gr {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.gr__rim {
width: 300px;
padding: 4px;
border-radius: 16px;
background: conic-gradient(from var(--angle), #2f6df6, #ff9ebb, #4cd4a6, #b9a0ff, #2f6df6);
animation-name: gr-spin;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin {
to {
--angle: 360deg;
}
}
.gr__face {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
border-radius: 12px;
background-color: #17141a;
background-image: linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06));
}
.gr__avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #ff9ebb);
color: #17141a;
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.gr__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
}
.gr__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.gr__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.gr__stat strong {
margin-right: 4px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.gr__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: none;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 12px;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gr__follow:hover {
background: rgba(255, 255, 255, 0.08);
}
.gr__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.gr__follow[aria-pressed=true] {
border-color: rgba(76, 212, 166, 0.65);
}
.gr__i {
display: inline-flex;
}
.gr__i--check {
display: none;
color: #4cd4a6;
}
.gr__follow[aria-pressed=true] .gr__i--star {
display: none;
}
.gr__follow[aria-pressed=true] .gr__i--check {
display: inline-flex;
}
@media (max-width: 340px) {
.gr__rim {
width: 272px;
}
.gr__face {
padding: 12px;
}
.gr__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 16px;
}
.gr__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.gr__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.gr__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.gr__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.gr__rim {
animation: none !important;
}
.gr__follow {
transition: none;
}
}// 실제 동작: 팔로우 토글(aria-pressed) · 첫 조작에 자동 루프 종료 (테두리 회전은 유지)
var root = document.querySelector('.gr');
var btn = document.getElementById('gr-follow');
var label = document.getElementById('gr-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? '팔로우' : '팔로잉';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./GradientRimCard.module.scss";
export type GradientRimCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconStar = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function GradientRimCard({
name = "한지운",
role = "모션 디자이너",
initials = "JW",
followers = "9,640",
posts = "210",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: GradientRimCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.gr} style={vars}>
<div className={styles.rim}>
<article className={styles.face}>
<div className={styles.avatar} aria-hidden="true">{initials}</div>
<h2 className={styles.name}>{name}</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconStar />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
</div>
);
}05 · 회전하는 그라디언트 테두리 — 측정 누적면적 2.851% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
각도를 담는 커스텀 속성을 @property 로 등록하는 것이 이 항목의 전부입니다.
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.gr__rim {
padding: $sp-1;
background: conic-gradient(from var(--angle),
$subject-blue, $subject-pink, $subject-mint, $subject-lilac, $subject-blue);
animation-name: gr-spin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin { to { --angle: 360deg; } }
06다크 글로우 링
어떤 움직임인가
어두운 카드 위에서 아바타를 감싼 고리가 숨 쉬듯 밝아졌다 잦아듭니다.

<main class="stage">
<div class="dg is-demo">
<article class="dg__card">
<div class="dg__ava">
<span class="dg__halo" aria-hidden="true"></span>
<span class="dg__ring" aria-hidden="true"></span>
<span class="dg__avatar" aria-hidden="true"><span class="dg__init">MJ</span></span>
<span class="dg__dot" aria-hidden="true"></span>
</div>
<h2 class="dg__name">서민재</h2>
<p class="dg__role">사운드 엔지니어</p>
<p class="dg__stats">
<span class="dg__stat"><strong>3,325</strong>팔로워</span>
<span class="dg__stat"><strong>57</strong>게시물</span>
</p>
<button class="dg__follow" type="button" id="dg-follow" aria-pressed="false">
<svg class="dg__i dg__i--plus" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg class="dg__i dg__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="dg__label" id="dg-label">팔로우</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.dg {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.dg__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: $sp-4;
background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .10);
border-radius: $r-card;
}
.dg__ava {
position: relative;
width: 68px;
height: 68px;
}
.dg__ring {
position: absolute;
inset: 0;
border: 2px solid $subject-mint;
border-radius: 50%;
box-shadow: 0 0 18px rgba($subject-mint, .55), inset 0 0 10px rgba($subject-mint, .45);
}
.dg__halo {
position: absolute;
inset: -8px;
border-radius: 50%;
box-shadow: 0 0 26px 4px rgba($subject-mint, .55);
opacity: .35;
pointer-events: none;
}
.dg__avatar {
position: absolute;
inset: 8px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(rgba($subject-mint, .28), rgba($subject-blue, .20)), rgba(255, 255, 255, .05);
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.dg__init {
color: $subject-cream;
filter: drop-shadow(0 0 6px rgba($subject-mint, .9));
}
.dg__dot {
position: absolute;
right: 0;
bottom: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-mint;
box-shadow: 0 0 10px rgba($subject-mint, .8);
}
.dg__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
}
.dg__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.dg__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.dg__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.dg__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: rgba($subject-mint, .10);
border: 1px solid rgba($subject-mint, .7);
border-radius: $r-control;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 0 14px rgba($subject-mint, .3);
transition: box-shadow $duration $easing, background $dur-quick $easing;
}
.dg__follow:hover { background: rgba($subject-mint, .2); box-shadow: 0 0 22px rgba($subject-mint, .55); }
.dg__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dg__follow[aria-pressed="true"] { box-shadow: 0 0 20px rgba($subject-mint, .5); }
.dg__i { display: inline-flex; }
.dg__i--check { display: none; color: $subject-mint; }
.dg__follow[aria-pressed="true"] .dg__i--plus { display: none; }
.dg__follow[aria-pressed="true"] .dg__i--check { display: inline-flex; }
// ── 자동 루프: 고리 번짐이 숨 쉬듯 오르내린다(아바타 고리·상태 점만 — 카드 바탕·무대는 고정).
// box-shadow 값은 고정하고 halo/dot 의 opacity·scale 만 움직인다. 복귀는 88% 안 ──
.dg.is-demo .dg__halo {
animation-name: dg-breathe;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.dg.is-demo .dg__dot {
animation-name: dg-dot-breathe;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
animation-fill-mode: backwards; // 지연 구간에 선언 상태가 한 프레임 찍히는 것을 막는다
}
// 번짐의 양끝을 벌린다 — backwards 로 첫 프레임의 밝은 장이 빠지면서 강도가 14.3 까지
// 내려가 임계(15) 를 못 넘었다 (실측 2026-09-12). 밝기 폭을 넓혀 다시 올린다
@keyframes dg-breathe {
0% { opacity: .18; transform: scale(.92); }
38% { opacity: 1; transform: scale(1.10); }
62% { opacity: 1; transform: scale(1.10); }
88% { opacity: .18; transform: scale(.92); }
100% { opacity: .18; transform: scale(.92); }
}
@keyframes dg-dot-breathe {
0% { opacity: .4; transform: scale(.86); }
38% { opacity: 1; transform: scale(1.12); }
62% { opacity: 1; transform: scale(1.12); }
88% { opacity: .4; transform: scale(.86); }
100% { opacity: .4; transform: scale(.86); }
}
@media (max-width: 340px) {
.dg__card { width: 272px; padding: $sp-2 $sp-3; }
.dg__ava { width: 56px; height: 56px; }
.dg__avatar { inset: 6px; font-size: 14px; line-height: 16px; }
.dg__halo { inset: -6px; }
.dg__dot { width: 10px; height: 10px; bottom: 0; }
.dg__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.dg__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.dg__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.dg__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__halo,
.dg.is-demo .dg__dot { animation: none !important; }
.dg__follow { transition: none; }
}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);
}
.dg {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.dg__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
}
.dg__ava {
position: relative;
width: 68px;
height: 68px;
}
.dg__ring {
position: absolute;
inset: 0;
border: 2px solid #4cd4a6;
border-radius: 50%;
box-shadow: 0 0 18px rgba(76, 212, 166, 0.55), inset 0 0 10px rgba(76, 212, 166, 0.45);
}
.dg__halo {
position: absolute;
inset: -8px;
border-radius: 50%;
box-shadow: 0 0 26px 4px rgba(76, 212, 166, 0.55);
opacity: 0.35;
pointer-events: none;
}
.dg__avatar {
position: absolute;
inset: 8px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(rgba(76, 212, 166, 0.28), rgba(47, 109, 246, 0.2)), rgba(255, 255, 255, 0.05);
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.dg__init {
color: #fff7e6;
filter: drop-shadow(0 0 6px rgba(76, 212, 166, 0.9));
}
.dg__dot {
position: absolute;
right: 0;
bottom: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4cd4a6;
box-shadow: 0 0 10px rgba(76, 212, 166, 0.8);
}
.dg__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
}
.dg__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.dg__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.dg__stat strong {
margin-right: 4px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.dg__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: rgba(76, 212, 166, 0.1);
border: 1px solid rgba(76, 212, 166, 0.7);
border-radius: 12px;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 0 14px rgba(76, 212, 166, 0.3);
transition: box-shadow 600ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__follow:hover {
background: rgba(76, 212, 166, 0.2);
box-shadow: 0 0 22px rgba(76, 212, 166, 0.55);
}
.dg__follow:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.dg__follow[aria-pressed=true] {
box-shadow: 0 0 20px rgba(76, 212, 166, 0.5);
}
.dg__i {
display: inline-flex;
}
.dg__i--check {
display: none;
color: #4cd4a6;
}
.dg__follow[aria-pressed=true] .dg__i--plus {
display: none;
}
.dg__follow[aria-pressed=true] .dg__i--check {
display: inline-flex;
}
.dg.is-demo .dg__halo {
animation-name: dg-breathe;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.dg.is-demo .dg__dot {
animation-name: dg-dot-breathe;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
animation-fill-mode: backwards;
}
@keyframes dg-breathe {
0% {
opacity: 0.18;
transform: scale(0.92);
}
38% {
opacity: 1;
transform: scale(1.1);
}
62% {
opacity: 1;
transform: scale(1.1);
}
88% {
opacity: 0.18;
transform: scale(0.92);
}
100% {
opacity: 0.18;
transform: scale(0.92);
}
}
@keyframes dg-dot-breathe {
0% {
opacity: 0.4;
transform: scale(0.86);
}
38% {
opacity: 1;
transform: scale(1.12);
}
62% {
opacity: 1;
transform: scale(1.12);
}
88% {
opacity: 0.4;
transform: scale(0.86);
}
100% {
opacity: 0.4;
transform: scale(0.86);
}
}
@media (max-width: 340px) {
.dg__card {
width: 272px;
padding: 8px 12px;
}
.dg__ava {
width: 56px;
height: 56px;
}
.dg__avatar {
inset: 6px;
font-size: 14px;
line-height: 16px;
}
.dg__halo {
inset: -6px;
}
.dg__dot {
width: 10px;
height: 10px;
bottom: 0;
}
.dg__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.dg__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.dg__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.dg__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__halo,
.dg.is-demo .dg__dot {
animation: none !important;
}
.dg__follow {
transition: none;
}
}// 실제 동작: 팔로우 토글(aria-pressed) · 첫 조작에 자동 루프 종료 (정적 번짐은 유지)
var root = document.querySelector('.dg');
var btn = document.getElementById('dg-follow');
var label = document.getElementById('dg-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? '팔로우' : '팔로잉';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./DarkGlowCard.module.scss";
export type DarkGlowCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function DarkGlowCard({
name = "서민재",
role = "사운드 엔지니어",
initials = "MJ",
followers = "3,325",
posts = "57",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: DarkGlowCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.dg} style={vars}>
<article className={styles.card}>
<div className={styles.ava}>
<span className={styles.halo} aria-hidden="true" />
<span className={styles.ring} aria-hidden="true" />
<span className={styles.avatar} aria-hidden="true"><span className={styles.init}>{initials}</span></span>
<span className={styles.dot} aria-hidden="true" />
</div>
<h2 className={styles.name}>{name}</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconUserPlus />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}06 · 다크 글로우 링 — 측정 누적면적 5.456% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
고리에는 box-shadow 를 안팎으로 걸고, 글자에는 모양을 따라가는 drop-shadow 를 걸어 번짐을 나눕니다.
.dg__ring {
border: 2px solid $subject-mint;
border-radius: 50%;
box-shadow: 0 0 18px rgba($subject-mint, .55), inset 0 0 10px rgba($subject-mint, .45);
}
.dg__init {
color: $subject-cream;
filter: drop-shadow(0 0 6px rgba($subject-mint, .9));
}
@keyframes dg-breathe {
0% { opacity: .18; transform: scale(.92); }
38% { opacity: 1; transform: scale(1.10); }
62% { opacity: 1; transform: scale(1.10); }
}
073D 플립 연락처
무엇이 보이나
카드가 세로축을 중심으로 반 바퀴 돌아 앞면 소개가 뒷면 연락처로 바뀝니다.

<main class="stage">
<div class="fc is-demo">
<div class="fc__inner" id="fc-inner">
<div class="fc__face fc__face--front" id="fc-front">
<span class="fc__avatar" aria-hidden="true">SA</span>
<p class="fc__name">오세아</p>
<p class="fc__role">UX 리서처</p>
<p class="fc__stats"><span class="fc__num">1,204</span> 팔로워 · <span class="fc__num">62</span> 게시물</p>
<div class="fc__actions">
<button class="fc__btn fc__btn--follow" type="button" id="fc-follow" aria-pressed="false">
<span class="fc__f-off"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>팔로우</span>
<span class="fc__f-on"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>팔로잉</span>
</button>
<button class="fc__btn fc__btn--ghost" type="button" id="fc-flip" aria-pressed="false">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>연락처 보기
</button>
</div>
</div>
<div class="fc__face fc__face--back" id="fc-back" aria-hidden="true">
<ul class="fc__list">
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg></span>
<span class="fc__meta"><span class="fc__k">이메일</span><span class="fc__v">[email protected]</span></span>
</li>
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg></span>
<span class="fc__meta"><span class="fc__k">전화</span><span class="fc__v">010-1234-5678</span></span>
</li>
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg></span>
<span class="fc__meta"><span class="fc__k">웹사이트</span><span class="fc__v">seaoh.dev</span></span>
</li>
</ul>
<button class="fc__btn fc__btn--ghost" type="button" id="fc-unflip" aria-pressed="false" tabindex="-1">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>앞면 보기
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // rotateY 플립 한 번의 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.fc {
position: relative;
width: 300px;
height: 212px;
perspective: 900px;
}
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fc__inner.is-back { transform: rotateY(180deg); }
// 양면 모두 backface-visibility hidden — 빠지면 뒷면 글자가 비쳐 보인다
.fc__face {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: $sp-4;
border-radius: $r-card;
box-shadow: $shadow-raised;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--front { background: #fff; color: $subject-ink; }
.fc__face--back {
background: $subject-cream;
color: $subject-ink;
transform: rotateY(180deg);
justify-content: center;
gap: $sp-4;
}
.fc__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: $sp-2;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-lilac);
color: $subject-ink;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.fc__name { margin: 0; font-weight: 800; font-size: 17px; line-height: 22px; color: $subject-ink; }
.fc__role { margin: 0 0 $sp-2; font-weight: 600; font-size: 12px; line-height: 16px; color: rgba(23, 20, 26, .6); }
.fc__stats { margin: 0; font-weight: 600; font-size: 13px; line-height: 18px; color: rgba(23, 20, 26, .72); }
.fc__num { font-weight: 800; font-variant-numeric: tabular-nums; color: $subject-ink; }
.fc__actions { display: flex; gap: $sp-2; margin-top: auto; }
.fc__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.fc__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fc__btn--follow { background: $color; color: #fff; }
.fc__btn--follow.is-on { background: rgba(23, 20, 26, .08); color: $subject-ink; }
.fc__btn--ghost {
background: transparent;
color: $subject-ink;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .3);
}
.fc__f-off,
.fc__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.fc__f-on { display: none; }
.fc__btn--follow.is-on .fc__f-off { display: none; }
.fc__btn--follow.is-on .fc__f-on { display: inline-flex; }
.fc__list {
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.fc__row { display: flex; align-items: center; gap: $sp-2; text-align: left; }
.fc__ic {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: $r-sm;
background: rgba($color, .16);
color: $color;
}
.fc__meta { display: flex; flex-direction: column; }
.fc__k { font-weight: 600; font-size: 10px; line-height: 12px; color: rgba(23, 20, 26, .55); }
.fc__v { font-weight: 700; font-size: 13px; line-height: 16px; color: $subject-ink; font-variant-numeric: tabular-nums; }
// ── 자동 루프(격자 미리보기 전용) ──
// ⚠ 여기의 steps(1, end) 컷은 미리보기 루프에만 쓴다. 실제 클릭(연락처 보기 / 앞면 보기)은
// 위 .fc__inner 의 `transition: transform $duration $easing` 그대로 부드럽게 뒤집힌다.
// 회전은 |cos θ| 곡선이라 90° 부근에서 프레임당 변화가 언제나 최대다 — 등속·이징 어느 쪽으로도
// "주황 배경에 흰 선 하나" 인 모서리뷰가 poster 로 뽑히는 걸 못 막는다 (실측 2026-09-12).
// 컷으로 갈아끼우면 모서리뷰 프레임이 0장이고 앞면·뒷면의 완성 장면만 남는다.
// 앞면 0~30% · 뒷면 30~76% · 앞면 76~100% (복귀 76% ✓).
.fc.is-demo .fc__inner {
animation-name: fc-flip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fc-flip {
0% { transform: rotateY(0deg); }
30% { transform: rotateY(180deg); }
76% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
// 컷만으로는 움직인 프레임이 2장뿐이라 양면 안쪽에 진짜 움직임을 더한다.
// 둘 다 transform 만 쓰고 페이드가 없어 반투명 중간 프레임이 생기지 않는다.
// 뒤집히는 두 프레임(30%·76%)에서는 모두 제자리 — 대표 이미지가 항상 "정돈된 면" 이 된다.
.fc.is-demo .fc__avatar {
animation-name: fc-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes fc-pop {
0% { transform: scale(1); }
6% { transform: scale(1.09); }
22% { transform: scale(1); }
80% { transform: scale(1); }
86% { transform: scale(1.09); }
96% { transform: scale(1); }
100% { transform: scale(1); }
}
.fc.is-demo .fc__ic {
animation-name: fc-tap;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__row:nth-child(2) .fc__ic { animation-delay: 83ms; }
.fc.is-demo .fc__row:nth-child(3) .fc__ic { animation-delay: 167ms; }
@keyframes fc-tap {
0% { transform: scale(1); }
36% { transform: scale(1); }
42% { transform: scale(1.22); }
58% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.fc { width: 272px; height: 164px; }
.fc__face { padding: $sp-3; }
.fc__face--back { gap: $sp-3; }
.fc__avatar { width: 44px; height: 44px; margin-bottom: $sp-1; font-size: 14px; line-height: 17px; }
.fc__name { font-size: 15px; line-height: 18px; }
.fc__role { margin-bottom: $sp-1; font-size: 11px; line-height: 14px; }
.fc__stats { font-size: 12px; line-height: 16px; }
.fc__btn { height: 32px; padding: 0 $sp-2; font-size: 12px; line-height: 14px; }
.fc__btn svg { width: 13px; height: 13px; }
.fc__list { gap: $sp-1; }
.fc__ic { width: 24px; height: 24px; }
.fc__ic svg { width: 13px; height: 13px; }
.fc__k { font-size: 9px; line-height: 11px; }
.fc__v { font-size: 12px; line-height: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__inner,
.fc.is-demo .fc__avatar,
.fc.is-demo .fc__ic { animation: none !important; }
.fc__inner { transition: none; }
.fc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fc {
position: relative;
width: 300px;
height: 212px;
perspective: 900px;
}
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fc__inner.is-back {
transform: rotateY(180deg);
}
.fc__face {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 16px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--front {
background: #fff;
color: #17141a;
}
.fc__face--back {
background: #fff7e6;
color: #17141a;
transform: rotateY(180deg);
justify-content: center;
gap: 16px;
}
.fc__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 8px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
color: #17141a;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.fc__name {
margin: 0;
font-weight: 800;
font-size: 17px;
line-height: 22px;
color: #17141a;
}
.fc__role {
margin: 0 0 8px;
font-weight: 600;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.6);
}
.fc__stats {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 18px;
color: rgba(23, 20, 26, 0.72);
}
.fc__num {
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.fc__actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.fc__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 12px;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fc__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fc__btn--follow {
background: #2f6df6;
color: #fff;
}
.fc__btn--follow.is-on {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
.fc__btn--ghost {
background: transparent;
color: #17141a;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.3);
}
.fc__f-off,
.fc__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.fc__f-on {
display: none;
}
.fc__btn--follow.is-on .fc__f-off {
display: none;
}
.fc__btn--follow.is-on .fc__f-on {
display: inline-flex;
}
.fc__list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.fc__row {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.fc__ic {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
color: #2f6df6;
}
.fc__meta {
display: flex;
flex-direction: column;
}
.fc__k {
font-weight: 600;
font-size: 10px;
line-height: 12px;
color: rgba(23, 20, 26, 0.55);
}
.fc__v {
font-weight: 700;
font-size: 13px;
line-height: 16px;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.fc.is-demo .fc__inner {
animation-name: fc-flip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fc-flip {
0% {
transform: rotateY(0deg);
}
30% {
transform: rotateY(180deg);
}
76% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(0deg);
}
}
.fc.is-demo .fc__avatar {
animation-name: fc-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fc-pop {
0% {
transform: scale(1);
}
6% {
transform: scale(1.09);
}
22% {
transform: scale(1);
}
80% {
transform: scale(1);
}
86% {
transform: scale(1.09);
}
96% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.fc.is-demo .fc__ic {
animation-name: fc-tap;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__row:nth-child(2) .fc__ic {
animation-delay: 83ms;
}
.fc.is-demo .fc__row:nth-child(3) .fc__ic {
animation-delay: 167ms;
}
@keyframes fc-tap {
0% {
transform: scale(1);
}
36% {
transform: scale(1);
}
42% {
transform: scale(1.22);
}
58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.fc {
width: 272px;
height: 164px;
}
.fc__face {
padding: 12px;
}
.fc__face--back {
gap: 12px;
}
.fc__avatar {
width: 44px;
height: 44px;
margin-bottom: 4px;
font-size: 14px;
line-height: 17px;
}
.fc__name {
font-size: 15px;
line-height: 18px;
}
.fc__role {
margin-bottom: 4px;
font-size: 11px;
line-height: 14px;
}
.fc__stats {
font-size: 12px;
line-height: 16px;
}
.fc__btn {
height: 32px;
padding: 0 8px;
font-size: 12px;
line-height: 14px;
}
.fc__btn svg {
width: 13px;
height: 13px;
}
.fc__list {
gap: 4px;
}
.fc__ic {
width: 24px;
height: 24px;
}
.fc__ic svg {
width: 13px;
height: 13px;
}
.fc__k {
font-size: 9px;
line-height: 11px;
}
.fc__v {
font-size: 12px;
line-height: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__inner,
.fc.is-demo .fc__avatar,
.fc.is-demo .fc__ic {
animation: none !important;
}
.fc__inner {
transition: none;
}
.fc__btn {
transition: none;
}
}// 실제 동작: 연락처 보기/앞면 보기가 rotateY 플립을 토글하고, 안 보이는 면은 키보드 초점이 가지 않게 막는다.
var root = document.querySelector('.fc');
var inner = document.getElementById('fc-inner');
var front = document.getElementById('fc-front');
var back = document.getElementById('fc-back');
var flip = document.getElementById('fc-flip');
var unflip = document.getElementById('fc-unflip');
var follow = document.getElementById('fc-follow');
function setBack(on) {
inner.classList.toggle('is-back', on);
flip.setAttribute('aria-pressed', String(on));
unflip.setAttribute('aria-pressed', String(!on));
front.setAttribute('aria-hidden', on ? 'true' : 'false');
back.setAttribute('aria-hidden', on ? 'false' : 'true');
front.querySelectorAll('button').forEach(function (b) {
if (on) { b.setAttribute('tabindex', '-1'); } else { b.removeAttribute('tabindex'); }
});
back.querySelectorAll('button').forEach(function (b) {
if (on) { b.removeAttribute('tabindex'); } else { b.setAttribute('tabindex', '-1'); }
});
}
flip.addEventListener('click', function () { setBack(true); flip.blur(); });
unflip.addEventListener('click', function () { setBack(false); unflip.blur(); });
follow.addEventListener('click', function () {
var on = follow.getAttribute('aria-pressed') !== 'true';
follow.setAttribute('aria-pressed', String(on));
follow.classList.toggle('is-on', on);
});
['pointerdown', 'click', 'keydown'].forEach(function (ev) {
root.addEventListener(ev, function () { root.classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FlipContactCard.module.scss";
export type FlipContactCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
const IconFlip = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>
);
const IconMail = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg>
);
const IconPhone = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg>
);
const IconGlobe = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg>
);
export default function FlipContactCard({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FlipContactCardProps) {
const [back, setBack] = useState(false);
const [following, setFollowing] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const followClass = following
? `${styles.btn} ${styles.btnFollow} ${styles.isOn}`
: `${styles.btn} ${styles.btnFollow}`;
return (
<div className={styles.fc} style={vars}>
<div className={back ? `${styles.inner} ${styles.isBack}` : styles.inner}>
<div className={`${styles.face} ${styles.faceFront}`} aria-hidden={back ? "true" : undefined}>
<span className={styles.avatar} aria-hidden="true">SA</span>
<p className={styles.name}>오세아</p>
<p className={styles.role}>UX 리서처</p>
<p className={styles.stats}>
<span className={styles.num}>1,204</span> 팔로워 · <span className={styles.num}>62</span> 게시물
</p>
<div className={styles.actions}>
<button
type="button"
className={followClass}
aria-pressed={following}
tabIndex={back ? -1 : undefined}
onClick={(): void => setFollowing(!following)}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
aria-pressed={back}
tabIndex={back ? -1 : undefined}
onClick={(): void => setBack(true)}
>
<IconFlip />연락처 보기
</button>
</div>
</div>
<div className={`${styles.face} ${styles.faceBack}`} aria-hidden={back ? undefined : "true"}>
<ul className={styles.list}>
<li className={styles.row}>
<span className={styles.ic}><IconMail /></span>
<span className={styles.meta}><span className={styles.k}>이메일</span><span className={styles.v}>[email protected]</span></span>
</li>
<li className={styles.row}>
<span className={styles.ic}><IconPhone /></span>
<span className={styles.meta}><span className={styles.k}>전화</span><span className={styles.v}>010-1234-5678</span></span>
</li>
<li className={styles.row}>
<span className={styles.ic}><IconGlobe /></span>
<span className={styles.meta}><span className={styles.k}>웹사이트</span><span className={styles.v}>seaoh.dev</span></span>
</li>
</ul>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
aria-pressed={!back}
tabIndex={back ? undefined : -1}
onClick={(): void => setBack(false)}
>
<IconFlip />앞면 보기
</button>
</div>
</div>
</div>
);
}07 · 3D 플립 연락처 — 측정 누적면적 43.991% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
양면 모두 자기 뒷모습을 감춰야 반대편 글자가 거울처럼 비치지 않습니다.
.fc { perspective: 900px; width: 300px; height: 212px; }
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fc__inner.is-back { transform: rotateY(180deg); }
.fc__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--back { transform: rotateY(180deg); }
08소셜 스탯 카운트업
화면에서 일어나는 일
세 숫자가 0에서 실제 값까지 프레임마다 올라갑니다.

<main class="stage">
<div class="pc is-demo">
<div class="pc__card">
<div class="pc__head">
<span class="pc__avatar" aria-hidden="true">TO</span>
<span class="pc__id">
<span class="pc__name">윤태오</span>
<span class="pc__role">데이터 분석가</span>
</span>
</div>
<div class="pc__stats" aria-live="polite">
<div class="pc__stat">
<span class="pc__num" id="pc-n0">0</span>
<span class="pc__label">팔로워</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
<div class="pc__stat">
<span class="pc__num" id="pc-n1">0</span>
<span class="pc__label">게시물</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
<div class="pc__stat">
<span class="pc__num" id="pc-n2">0</span>
<span class="pc__label">좋아요</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
</div>
<button class="pc__btn" type="button" id="pc-follow" aria-pressed="false">
<span class="pc__f-off"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>팔로우</span>
<span class="pc__f-on"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>팔로잉</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 팔로우 버튼 색 전환
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pc { display: flex; justify-content: center; }
.pc__card {
box-sizing: border-box;
width: 300px;
padding: $sp-4;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.pc__head { display: flex; align-items: center; gap: $sp-3; }
.pc__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-mint);
color: $subject-ink;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.pc__id { display: flex; flex-direction: column; }
.pc__name { font-weight: 800; font-size: 17px; line-height: 22px; color: $subject-ink; }
.pc__role { font-weight: 600; font-size: 12px; line-height: 16px; color: rgba(23, 20, 26, .6); }
.pc__stats { display: flex; gap: $sp-2; margin: $sp-3 0; }
.pc__stat {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: $sp-1;
}
.pc__num {
font-weight: 800;
font-size: 20px;
line-height: 24px;
color: $subject-ink;
font-variant-numeric: tabular-nums; // 자릿수가 바뀌어도 칸이 안 흔들린다
}
.pc__label { font-weight: 600; font-size: 11px; line-height: 14px; color: rgba(23, 20, 26, .6); }
.pc__bar {
display: block;
height: 4px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.pc__bar i {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: $color;
transform-origin: left center; // 규칙8: 스탯막대 left 채움
transform: scaleX(1);
}
.pc__btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 100%;
height: 36px;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background $duration $easing, color $duration $easing;
}
.pc__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.pc__btn.is-on { background: rgba(23, 20, 26, .08); color: $subject-ink; }
.pc__f-off,
.pc__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.pc__f-on { display: none; }
.pc__btn.is-on .pc__f-off { display: none; }
.pc__btn.is-on .pc__f-on { display: inline-flex; }
// ── 자동 루프(측정용): requestAnimationFrame 카운트업은 CSS 시계가 아니라 측정에 안 잡히므로
// 스탯 칸이 차례로 떠오르고 진행 막대가 차오르는 격자 움직임을 CSS 로 따로 둔다.
// 시차는 animation: 단축이 delay 를 리셋하므로 낱개 속성 + animation-delay 로 준다. ──
// ⚠ 시차 rise 를 opacity 로 부드럽게 하면 poster 에 "반투명 유령" 칸이 찍힌다 (실측 2026-09-12).
// 그래서 칸의 등장·퇴장은 steps(1, end) 한 프레임 컷이고, 움직인 프레임은 얇은 진행 막대가 채운다.
// 막대 3개는 세 번째 칸이 켜지는 바로 그 프레임에 함께 나타난다 — 그 프레임이 언제나 최대 변화라
// "세 칸이 다 보이는 상태" 가 poster 로 뽑힌다. 퇴장은 막대 → 칸 순서로 나눠 그 값을 못 넘게 한다.
// 지연이 걸린 애니메이션에는 animation-fill-mode: backwards 가 꼭 필요하다 —
// 없으면 지연 구간에 기본 상태(보임)가 그대로 보여 첫 프레임이 번쩍인다.
.pc.is-demo .pc__stat {
animation-name: pc-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pc.is-demo .pc__stat:nth-child(2) { animation-delay: 83ms; }
.pc.is-demo .pc__stat:nth-child(3) { animation-delay: 167ms; }
.pc.is-demo .pc__bar {
animation-name: pc-track;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pc.is-demo .pc__bar i {
animation-name: pc-fill;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes pc-in {
0% { opacity: 0; }
11% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pc-track {
0% { opacity: 0; }
19% { opacity: 1; }
79% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pc-fill {
0% { transform: scaleX(.42); }
19% { transform: scaleX(.42); }
62% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@media (max-width: 340px) {
.pc__card { width: 272px; padding: $sp-3; }
.pc__head { gap: $sp-2; }
.pc__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 17px; }
.pc__name { font-size: 15px; line-height: 18px; }
.pc__role { font-size: 11px; line-height: 14px; }
.pc__stats { margin: $sp-2 0; gap: $sp-1; }
.pc__num { font-size: 17px; line-height: 20px; }
.pc__label { font-size: 10px; line-height: 12px; }
.pc__bar { height: 3px; }
.pc__btn { height: 32px; font-size: 12px; line-height: 14px; }
.pc__btn svg { width: 13px; height: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.pc.is-demo .pc__stat,
.pc.is-demo .pc__bar,
.pc.is-demo .pc__bar i { animation: none !important; }
.pc__btn { transition: none; }
}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);
}
.pc {
display: flex;
justify-content: center;
}
.pc__card {
box-sizing: border-box;
width: 300px;
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.pc__head {
display: flex;
align-items: center;
gap: 12px;
}
.pc__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #4cd4a6);
color: #17141a;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.pc__id {
display: flex;
flex-direction: column;
}
.pc__name {
font-weight: 800;
font-size: 17px;
line-height: 22px;
color: #17141a;
}
.pc__role {
font-weight: 600;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.6);
}
.pc__stats {
display: flex;
gap: 8px;
margin: 12px 0;
}
.pc__stat {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 4px;
}
.pc__num {
font-weight: 800;
font-size: 20px;
line-height: 24px;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.pc__label {
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(23, 20, 26, 0.6);
}
.pc__bar {
display: block;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.pc__bar i {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(1);
}
.pc__btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 36px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pc__btn.is-on {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
.pc__f-off,
.pc__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pc__f-on {
display: none;
}
.pc__btn.is-on .pc__f-off {
display: none;
}
.pc__btn.is-on .pc__f-on {
display: inline-flex;
}
.pc.is-demo .pc__stat {
animation-name: pc-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pc.is-demo .pc__stat:nth-child(2) {
animation-delay: 83ms;
}
.pc.is-demo .pc__stat:nth-child(3) {
animation-delay: 167ms;
}
.pc.is-demo .pc__bar {
animation-name: pc-track;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pc.is-demo .pc__bar i {
animation-name: pc-fill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes pc-in {
0% {
opacity: 0;
}
11% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pc-track {
0% {
opacity: 0;
}
19% {
opacity: 1;
}
79% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pc-fill {
0% {
transform: scaleX(0.42);
}
19% {
transform: scaleX(0.42);
}
62% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@media (max-width: 340px) {
.pc__card {
width: 272px;
padding: 12px;
}
.pc__head {
gap: 8px;
}
.pc__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 17px;
}
.pc__name {
font-size: 15px;
line-height: 18px;
}
.pc__role {
font-size: 11px;
line-height: 14px;
}
.pc__stats {
margin: 8px 0;
gap: 4px;
}
.pc__num {
font-size: 17px;
line-height: 20px;
}
.pc__label {
font-size: 10px;
line-height: 12px;
}
.pc__bar {
height: 3px;
}
.pc__btn {
height: 32px;
font-size: 12px;
line-height: 14px;
}
.pc__btn svg {
width: 13px;
height: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc.is-demo .pc__stat,
.pc.is-demo .pc__bar,
.pc.is-demo .pc__bar i {
animation: none !important;
}
.pc__btn {
transition: none;
}
}// 실제 동작: requestAnimationFrame 카운트업(로케일 의존 없는 3자리 콤마).
// 팔로우를 누르면 팔로워 값이 1 늘고 그 숫자만 다시 카운트업된다.
var root = document.querySelector('.pc');
var nums = [0, 1, 2].map(function (i) { return document.getElementById('pc-n' + i); });
var targets = [12480, 316, 48920];
var current = [0, 0, 0];
var runs = [0, 0, 0];
function commas(n) {
var s = String(Math.round(n));
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function countTo(i, to, ms) {
var run = ++runs[i];
// 애니메이션 축소를 켠 사람에게는 숫자를 세지 않고 최종값을 바로 넣는다 (React 판과 같은 가드)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
current[i] = to;
nums[i].textContent = commas(to);
return;
}
var from = current[i], t0 = performance.now();
function frame(now) {
if (runs[i] !== run) return;
var p = Math.min(1, (now - t0) / ms);
current[i] = Math.round(from + (to - from) * easeOutCubic(p));
nums[i].textContent = commas(current[i]);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
nums.forEach(function (_, i) { countTo(i, targets[i], 1400); });
var follow = document.getElementById('pc-follow');
follow.addEventListener('click', function () {
var on = follow.getAttribute('aria-pressed') !== 'true';
follow.setAttribute('aria-pressed', String(on));
follow.classList.toggle('is-on', on);
targets[0] += on ? 1 : -1;
countTo(0, targets[0], 600);
});
['pointerdown', 'click', 'keydown'].forEach(function (ev) {
root.addEventListener(ev, function () { root.classList.remove('is-demo'); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StatCountUpCard.module.scss";
export type StatCountUpCardProps = {
duration?: number;
easing?: string;
color?: string;
countMs?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Triple = [number, number, number];
// useRef 는 항상 `T | null` 을 돌려주므로 값 보관용 상자는 널이 아닌 타입으로 좁혀 쓴다
type Box<T> = { current: T };
const BASE: Triple = [12480, 316, 48920];
function commas(n: number): string {
return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function StatCountUpCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
countMs = 1400,
}: StatCountUpCardProps) {
const [shown, setShown] = useState<Triple>([0, 0, 0]);
const [following, setFollowing] = useState(false);
const shownRef = useRef<Triple>([0, 0, 0]) as Box<Triple>;
const targetsRef = useRef<Triple>([...BASE] as Triple) as Box<Triple>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function count(to: Triple, ms: number): void {
cancelAnimationFrame(rafRef.current);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
const done: Triple = [...to] as Triple;
shownRef.current = done;
setShown(done);
return;
}
const from: Triple = [...shownRef.current] as Triple;
const t0 = performance.now();
const step = (now: number): void => {
const p = Math.min(1, (now - t0) / ms);
const next = [0, 0, 0] as Triple;
for (let i = 0; i < 3; i += 1) {
next[i] = Math.round(from[i] + (to[i] - from[i]) * easeOutCubic(p));
}
shownRef.current = next;
setShown(next);
if (p < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
useEffect((): (() => void) => {
count([...BASE] as Triple, countMs);
return (): void => cancelAnimationFrame(rafRef.current);
// 마운트 시 1회 — 값은 BASE 로 고정
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onFollow(): void {
const next = !following;
setFollowing(next);
targetsRef.current = [targetsRef.current[0] + (next ? 1 : -1), targetsRef.current[1], targetsRef.current[2]];
count(targetsRef.current, 600);
}
return (
<div className={styles.pc} style={vars}>
<div className={styles.card}>
<div className={styles.head}>
<span className={styles.avatar} aria-hidden="true">TO</span>
<span className={styles.id}>
<span className={styles.name}>윤태오</span>
<span className={styles.role}>데이터 분석가</span>
</span>
</div>
<div className={styles.stats} aria-live="polite">
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[0])}</span>
<span className={styles.label}>팔로워</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[1])}</span>
<span className={styles.label}>게시물</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[2])}</span>
<span className={styles.label}>좋아요</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
</div>
<button
type="button"
className={following ? `${styles.btn} ${styles.isOn}` : styles.btn}
aria-pressed={following}
onClick={onFollow}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
</div>
</div>
);
}08 · 소셜 스탯 카운트업 — 측정 누적면적 2.694% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
팔로우를 누르면 팔로워 값이 1 늘어나고 그 숫자만 다시 세며, 이전 카운트가 겹쳐 돌지 않도록 실행마다 번호를 매깁니다.
function countTo(i, to, ms) {
var run = ++runs[i];
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
current[i] = to;
nums[i].textContent = commas(to);
return;
}
var from = current[i], t0 = performance.now();
function frame(now) {
if (runs[i] !== run) return;
var p = Math.min(1, (now - t0) / ms);
current[i] = Math.round(from + (to - from) * easeOutCubic(p));
nums[i].textContent = commas(current[i]);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
09팀 카드 그리드 확장
어떤 움직임인가
타일 하나가 넓어지면 직함과 팔로우 버튼이 아래에서 올라오고, 나머지 둘이 같은 시간 동안 좁아져 전체 폭이 그대로 유지됩니다.

<main class="stage">
<div class="tg is-demo">
<p class="tg__head">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
팀 멤버
</p>
<div class="tg__row">
<div class="tg__tile is-open">
<button class="tg__main" type="button" aria-expanded="true">
<span class="tg__avatar tg__avatar--a" aria-hidden="true">JH</span>
<span class="tg__name">강주하</span>
<span class="tg__role">팀 리드</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>팔로우</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>팔로잉</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--b" aria-hidden="true">ES</span>
<span class="tg__name">노은설</span>
<span class="tg__role">프로덕트 디자이너</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>팔로우</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>팔로잉</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--c" aria-hidden="true">GO</span>
<span class="tg__name">임가온</span>
<span class="tg__role">프론트엔드 개발자</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>팔로우</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>팔로잉</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 타일이 넓어지는 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.tg { width: 320px; }
.tg__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-3;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: $subject-cream;
}
.tg__head svg { flex: none; color: rgba(255, 255, 255, .55); }
.tg__row { display: flex; gap: $sp-2; }
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: $r-card;
background: rgba(255, 255, 255, .07);
overflow: hidden; // 접힌 타일에서 넘치는 글자는 잘린다
flex-grow: 1;
transition: flex-grow $duration $easing, background $duration $easing;
}
.tg__tile.is-open { flex-grow: 2.6; background: rgba(255, 255, 255, .15); }
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-2 $sp-10;
border: 0;
background: transparent;
color: $subject-cream;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible { outline: 2px solid $color; outline-offset: -2px; border-radius: $r-card; }
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: $subject-ink;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
.tg__avatar--b { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
.tg__avatar--c { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: $subject-cream;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, .62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.tg__tile.is-open .tg__role { opacity: 1; transform: translateY(0); }
.tg__follow {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: $sp-3;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 28px;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $dur-quick $easing;
}
.tg__tile.is-open .tg__follow { opacity: 1; transform: translateY(0); pointer-events: auto; }
.tg__follow:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.tg__follow.is-on { background: rgba(255, 255, 255, .18); color: $subject-cream; }
.tg__f-off,
.tg__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.tg__f-on { display: none; }
.tg__follow.is-on .tg__f-off { display: none; }
.tg__follow.is-on .tg__f-on { display: inline-flex; }
// ── 자동 루프: 1번 → 2번 → 3번 순으로 flex-grow 가 커졌다 작아진다.
// 시차는 animation: 단축이 delay 를 0 으로 리셋하므로 낱개 속성 + animation-delay 로 준다.
// 마지막 타일도 87%(1744ms) 안에 1 로 돌아오고 끝까지 그대로 → 텔레포트 없음.
// ⚠ 직함·팔로우 버튼을 opacity 로 부드럽게 띄우면 poster 에 "반투명 유령" 이 찍힌다 (실측 2026-09-12).
// 그래서 (1) 펼치기는 ease-in — 다 펼쳐지는 마지막 한 프레임이 가장 크게 변하고,
// (2) 직함·버튼은 바로 그 프레임에 steps(1, end) 로 컷 등장하며,
// (3) 되돌아오기는 linear 로 여러 프레임에 고르게 나눠 그 프레임을 넘지 않게 한다.
// → 최대 변화 프레임 = "완전히 펼쳐진 타일 + 또렷한 직함·버튼" 이 poster 가 된다. ──
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow { animation-delay: 583ms; }
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow { animation-delay: 1166ms; }
@keyframes tg-grow {
0% { flex-grow: 1; animation-timing-function: cubic-bezier(.55, 0, .85, .25); }
12.2% { flex-grow: 2.6; }
16.4% { flex-grow: 2.6; }
28.9% { flex-grow: 1; }
100% { flex-grow: 1; }
}
@keyframes tg-detail {
0% { opacity: 0; transform: translateY(8px); }
12.2% { opacity: 1; transform: translateY(0); }
20% { opacity: 0; transform: translateY(8px); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tg { width: 272px; }
.tg__head { gap: $sp-1; margin-bottom: $sp-2; font-size: 12px; line-height: 15px; }
.tg__row { gap: $sp-1; }
.tg__tile { height: 104px; }
.tg__main { gap: 0; padding: $sp-2 $sp-1 $sp-8; }
.tg__avatar { width: 28px; height: 28px; font-size: 11px; line-height: 14px; }
.tg__name { font-size: 12px; line-height: 15px; }
.tg__role { font-size: 10px; line-height: 12px; }
.tg__follow { left: $sp-1; right: $sp-1; bottom: $sp-2; height: 28px; font-size: 11px; line-height: 13px; }
.tg__follow svg { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow { animation: none !important; }
.tg__tile, .tg__role, .tg__follow { transition: none; }
}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);
}
.tg {
width: 320px;
}
.tg__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: #fff7e6;
}
.tg__head svg {
flex: none;
color: rgba(255, 255, 255, 0.55);
}
.tg__row {
display: flex;
gap: 8px;
}
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
flex-grow: 1;
transition: flex-grow 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open {
flex-grow: 2.6;
background: rgba(255, 255, 255, 0.15);
}
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px 40px;
border: 0;
background: transparent;
color: #fff7e6;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
border-radius: 16px;
}
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.tg__avatar--b {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.tg__avatar--c {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: #fff7e6;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, 0.62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__role {
opacity: 1;
transform: translateY(0);
}
.tg__follow {
position: absolute;
left: 8px;
right: 8px;
bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 28px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__follow {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tg__follow:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.tg__follow.is-on {
background: rgba(255, 255, 255, 0.18);
color: #fff7e6;
}
.tg__f-off,
.tg__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tg__f-on {
display: none;
}
.tg__follow.is-on .tg__f-off {
display: none;
}
.tg__follow.is-on .tg__f-on {
display: inline-flex;
}
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow {
animation-delay: 583ms;
}
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow {
animation-delay: 1166ms;
}
@keyframes tg-grow {
0% {
flex-grow: 1;
animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.25);
}
12.2% {
flex-grow: 2.6;
}
16.4% {
flex-grow: 2.6;
}
28.9% {
flex-grow: 1;
}
100% {
flex-grow: 1;
}
}
@keyframes tg-detail {
0% {
opacity: 0;
transform: translateY(8px);
}
12.2% {
opacity: 1;
transform: translateY(0);
}
20% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tg {
width: 272px;
}
.tg__head {
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
line-height: 15px;
}
.tg__row {
gap: 4px;
}
.tg__tile {
height: 104px;
}
.tg__main {
gap: 0;
padding: 8px 4px 32px;
}
.tg__avatar {
width: 28px;
height: 28px;
font-size: 11px;
line-height: 14px;
}
.tg__name {
font-size: 12px;
line-height: 15px;
}
.tg__role {
font-size: 10px;
line-height: 12px;
}
.tg__follow {
left: 4px;
right: 4px;
bottom: 8px;
height: 28px;
font-size: 11px;
line-height: 13px;
}
.tg__follow svg {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation: none !important;
}
.tg__tile, .tg__role, .tg__follow {
transition: none;
}
}// 실제 동작: 호버·클릭·화살표로 타일 하나가 넓어지고(flex-grow) 그 안의 직함·팔로우가 올라온다.
var root = document.querySelector('.tg');
var tiles = [].slice.call(document.querySelectorAll('.tg__tile'));
var mains = tiles.map(function (t) { return t.querySelector('.tg__main'); });
var at = 0;
function open(i) {
at = Math.max(0, Math.min(i, tiles.length - 1));
tiles.forEach(function (t, k) {
t.classList.toggle('is-open', k === at);
mains[k].setAttribute('aria-expanded', String(k === at));
});
}
tiles.forEach(function (tile, i) {
tile.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('click', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
tile.querySelector('.tg__follow').addEventListener('click', function () {
root.classList.remove('is-demo');
var b = tile.querySelector('.tg__follow');
var on = b.getAttribute('aria-pressed') !== 'true';
b.setAttribute('aria-pressed', String(on));
b.classList.toggle('is-on', on);
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamGridCard.module.scss";
export type TeamGridCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
// useRef 는 항상 `T | null` 을 돌려주므로 목록 보관용 상자는 널이 아닌 타입으로 좁혀 쓴다
type Box<T> = { current: T };
type Member = { initials: string; name: string; role: string; tone: string };
const MEMBERS: Member[] = [
{ initials: "JH", name: "강주하", role: "팀 리드", tone: "a" },
{ initials: "ES", name: "노은설", role: "프로덕트 디자이너", tone: "b" },
{ initials: "GO", name: "임가온", role: "프론트엔드 개발자", tone: "c" },
];
const IconUsers = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
);
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function TeamGridCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TeamGridCardProps) {
const [at, setAt] = useState(0);
const [followed, setFollowed] = useState<boolean[]>([false, false, false]);
const mainsRef = useRef<(HTMLButtonElement | null)[]>([null, null, null]) as Box<(HTMLButtonElement | null)[]>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onKey(e: KeyEvt, i: number): void {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : MEMBERS.length - 1)) % MEMBERS.length;
setAt(next);
const el = mainsRef.current[next];
if (el) el.focus({ preventScroll: true });
}
function toggleFollow(i: number): void {
setFollowed(followed.map((v: boolean, k: number): boolean => (k === i ? !v : v)));
}
return (
<div className={styles.tg} style={vars}>
<p className={styles.head}><IconUsers />팀 멤버</p>
<div className={styles.row}>
{MEMBERS.map((m: Member, i: number) => {
const open = i === at;
const tone = m.tone === "a" ? styles.toneA : m.tone === "b" ? styles.toneB : styles.toneC;
return (
<div
key={m.initials}
className={open ? `${styles.tile} ${styles.isOpen}` : styles.tile}
onPointerEnter={(): void => setAt(i)}
>
<button
type="button"
ref={(el: HTMLButtonElement | null): void => { mainsRef.current[i] = el; }}
className={styles.main}
aria-expanded={open}
onClick={(): void => setAt(i)}
onKeyDown={(e: KeyEvt): void => onKey(e, i)}
>
<span className={`${styles.avatar} ${tone}`} aria-hidden="true">{m.initials}</span>
<span className={styles.name}>{m.name}</span>
<span className={styles.role}>{m.role}</span>
</button>
<button
type="button"
className={followed[i] ? `${styles.follow} ${styles.isOn}` : styles.follow}
aria-pressed={followed[i]}
tabIndex={open ? undefined : -1}
onClick={(): void => toggleFollow(i)}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
</div>
);
})}
</div>
</div>
);
}09 · 팀 카드 그리드 확장 — 측정 누적면적 22.797% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
좌우 화살표로 초점을 옮길 때는 화면이 따라 스크롤되지 않게 막습니다.
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
어디서 깨지는가 — 함정
이 편의 함정은 스타일이 아니라 썸네일에서 나왔습니다. 격자와 공유 카드에 쓰는 대표 이미지는 2초를 24장으로 뽑은 뒤 "직전 장과 가장 크게 달라진 장"을 고르는데, 07번 플립이 늘 정확히 모서리에서 본 장면으로 뽑혔습니다. 주황 배경에 흰 선 한 줄만 남은 그림이었습니다. 우연이 아닙니다. 세로축으로 돌아간 면의 화면 폭은 회전각의 코사인이고, 그 변화율은 사인이라 90도 부근에서 언제나 최대입니다. 등속으로 돌리든 이징을 넣든 가장 크게 변한 장은 모서리입니다.
같은 병이 세 항목에 더 있었습니다. 08번 스탯 세 칸과 09번 직함·버튼을 투명도로 부드럽게 띄웠더니 대표 이미지가 반투명 유령이 됐고, 04번 밑줄은 등속으로 그어서 시작 직후의 짧은 작대기가 뽑혀 오타처럼 보였습니다. 고친 방향은 셋 다 같습니다. 서서히 바뀌는 구간을 없애고, 완성된 상태가 한 장에 떨어지게 맞췄습니다. 08번 스탯 칸과 09번 직함·버튼은 등장과 퇴장을 steps(1, end) 로 한 장에 갈아 끼웠고, 04번 밑줄은 마지막 한 칸을 한 장 간격인 83.3밀리초 안에 몰아 넣어 선이 끝까지 그어지는 장이 최대 변화가 되게 했습니다. 07번은 미리보기 루프만 컷으로 바꾸고 실제 클릭은 부드러운 전환 그대로 뒀습니다. 컷으로 바꾸자 회전하는 동안 화면을 쓸고 지나가던 면적이 빠지면서 변한 픽셀 면적이 56.2%에서 44.0%로 내려갔고, 대신 양면 안쪽에 투명도를 쓰지 않는 작은 움직임을 넣어 움직인 장을 10장에서 18장으로 올렸습니다. 컷 하나만 남기면 움직임이 두 장뿐이라 애니메이션이 없는 것으로 측정되기 때문입니다.
지연을 준 애니메이션에서 하나 더 걸렸습니다. animation-delay 만 주고 animation-fill-mode: backwards 를 빼면 지연 구간 동안 요소가 원래 상태 그대로 그려집니다. 08번 스탯 세 칸이 첫 장에 다 보였다가 꺼진 뒤 차례로 다시 켜졌습니다. 지연 구간에는 애니메이션이 아직 적용되지 않는데, 프레임을 뽑는 쪽은 그 시각을 그대로 찍기 때문입니다. 시차를 줄 때 animation 단축을 쓰면 지연 자체가 0으로 리셋되는 것도 같은 계열입니다 — 지연을 쓰는 03·04·06·07·08·09번은 전부 낱개 속성으로 씁니다.
접근성
색 대비는 짐작하지 않고 합성한 실제 색으로 계산했습니다. 반투명 판은 아래 배경과 섞인 뒤의 색이 진짜 배경이라, 눈으로는 흰 글자가 잘 보이는 것 같아도 숫자가 다릅니다.
| 항목 | 글자 / 바탕 | 대비 | 판정 |
|---|---|---|---|
| 01 글래스 | 먹색 / 서리판 (255, 130, 98) | 7.49:1 | 통과 |
| 01 글래스 (버린 안) | 흰색 / 같은 서리판 | 2.44:1 | 미달 |
| 02 네오브루탈 | 흰색 / 파랑 알약 | 4.53:1 | 통과 |
| 03 클레이 | 먹색 62% / 분홍 카드 | 4.79:1 | 통과 |
| 04 미니멀 | 먹색 64% / 흰 카드 | 5.44:1 | 통과 |
| 06 다크 | 민트 상태 점 / 어두운 판 | 8.64:1 | 통과 |
| 09 팀 | 크림 / 펼친 타일 | 10.99:1 | 통과 |
01번은 처음에 흰 글자였고 2.44:1 로 기준 미달이었습니다. 서리판을 더 희게 올리고 글자를 먹색으로 바꾸자 7.49:1 이 됐습니다. 04번 보조 글자도 투명도 55%에서 4.04:1 이라 64%로 올렸습니다. 명함에 숫자를 새겨 넣어야 한다면 css 그래프 만들기 SVG로의 좌표 계산을 그대로 빌려 오면 됩니다. 이렇게 대비까지 손본 아홉 벌 소스는 압축 비밀번호 kx4qjk9q를 넣으면 열리는 zip 안에 있고, 바닐라와 React 판이 같은 값을 씁니다.
prefers-reduced-motion: reduce 에서는 아홉 개 모두 자동 재생 루프를 끕니다. 01번 얼룩은 제자리에 서고, 02번은 눌림 반복 대신 실제 :active 만 남고, 03번 호흡과 06번 번짐은 기본 상태에서 멈추되 점토 그림자와 고리 불빛은 그대로 보이고, 05번 테두리는 회전을 멈추되 그라디언트는 그대로 보입니다. 07번은 뒤집기 전환이 꺼지고 클릭하면 즉시 뒷면이 되며, 08번은 스탯 칸이 차례로 떠오르는 루프가 멈추고, 09번은 펼치기 전환 없이 상태만 바뀝니다. 08번 숫자 카운트업만 CSS 가 아니라 자바스크립트라 미디어 질의가 자동으로 닿지 않으므로, 바닐라와 React 판 양쪽에서 matchMedia('(prefers-reduced-motion: reduce)') 를 한 번 읽어 세지 않고 최종값을 바로 넣습니다. 어느 경우에도 팔로우 여부·펼침 여부 같은 상태 값은 남습니다. 07번의 3D 회전 속성은 MDN 의 transform-style 문서에 정리돼 있습니다.
같은 카드 계열을 더 찾는다면 CSS 카드 효과 9가지와 로그인 화면 디자인 9가지가 이웃입니다.
FAQ
프로필 카드 만들기에서 아바타 이미지 없이 이니셜만 써도 괜찮나요?
괜찮습니다. 아홉 개 전부 이미지 파일 없이 CSS 로 칠한 원 안에 이니셜 두 글자를 넣었습니다 — 대부분 그라디언트이고 03·04번만 단색입니다. 사진이 아직 없는 계정에서 회색 사람 모양 대신 쓰면 빈 자리가 덜 눈에 띄고, 네트워크 요청이 하나도 늘지 않습니다. 나중에 사진이 생기면 원 안쪽을 background-image 로 덮기만 하면 되고 바깥 크기와 테두리는 그대로 둡니다. 프로필 카드가 화면 첫 조각이 되는 배치는 히어로 섹션 디자인 패턴에서 통째로 다룹니다.
backdrop-filter 를 넣었는데 아무것도 안 흐려집니다
그 요소 뒤에 흐려질 것이 없기 때문입니다. backdrop-filter 는 요소 자신이 아니라 뒤에 깔린 것을 흐리게 하므로, 단색 배경 위에 올리면 흐린 단색이 되어 차이가 안 보입니다. 01번처럼 카드 뒤에 색 얼룩이나 사진을 깔아야 효과가 드러납니다. 카드 배경이 완전히 불투명해도 마찬가지로 효과가 드러나지 않으니 알파 값을 반드시 1 미만으로 둡니다.
그라디언트 테두리가 안 돕니다
@property 등록이 빠졌을 가능성이 큽니다. 그냥 선언한 커스텀 속성은 브라우저가 문자열로만 다루기 때문에 두 값 사이를 보간하지 못하고, 각도가 0도에서 360도로 한 번에 점프해 멈춘 것처럼 보입니다. 05번처럼 syntax: "<angle>" 과 initial-value 를 적어 등록해야 각도가 실제로 이어집니다.









