9 Profile Card Design Styles, Copy-Paste Ready
Profile card design is the job of deciding what material a fixed block of content wears: avatar, name, role, stats, follow button.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Glassmorphism
- 02 Neo-brutalist offset
- 03 Claymorphism
- 04 Minimal line
- 05 Spinning gradient border
- 06 Dark glow ring
- 07 3D flip contact
- 08 Social stat count-up
- 09 Team grid expand
The first six change nothing but the surface of a single card. All six lay out the same fields — avatar, name, role, two stats, and a follow button — so reading one style block against the next shows exactly which declarations carry the look. The order tracks how much each one does with light: glass that blurs whatever sits behind it (01), then neo-brutalism that trades light for shadow used as a solid shape (02), clay that stacks inner and outer shadows into volume (03), a minimal card that throws away every shadow and keeps only hairlines (04), a rim that glows on its own (05), and neon that bleeds in the dark (06). The last three add structure instead of surface: a back face (07), time (08), and neighbors (09). In every html css profile card design here the follow button really flips aria-pressed and swaps both label and icon. Raising the face to section scale is what Landing Page Sections That Build Trust handles, and the moment a price enters the card, Pricing Table Design takes over.
01Glassmorphism
What this profile card ui design shows
Two color blobs drift behind the card, and the translucent surface blurs them as they pass.

<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">Seorin Lee</p>
<p class="gf__role">Product designer</p>
</div>
</div>
<div class="gf__stats">
<div class="gf__stat"><strong class="gf__num">2,431</strong><span class="gf__lab">Followers</span></div>
<div class="gf__stat"><strong class="gf__num">128</strong><span class="gf__lab">Posts</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">Follow</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;
}
}// 실제 동작: Follow 버튼 토글 — 아이콘 두 개를 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 ? 'Following' : 'Follow';
});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 · Glassmorphism — Measured area 33.061% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A single declaration turns the surface into frosted glass, but the effect only shows if something real sits behind it.
.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%);
}
02Neo-brutalist offset
What happens on screen
A heavy outline and a flat shadow pushed to the lower right lift the card like a piece of cut paper.

<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">Dohyun Park</p>
<p class="nb__role">Front-end dev</p>
</div>
</div>
<div class="nb__stats">
<div class="nb__stat"><strong class="nb__num">1,842</strong><span class="nb__lab">Followers</span></div>
<div class="nb__stat"><strong class="nb__num">36</strong><span class="nb__lab">Posts</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">Follow</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;
}
}// 실제 동작: Follow 버튼 토글. 첫 조작에 자동 루프가 끊기고 :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 ? 'Following' : 'Follow';
});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 · Neo-brutalist offset — Measured area 18.2% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Press it and the card lands exactly on the shadow, which disappears underneath it.
.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; }
}
03Claymorphism
What moves
Three stacked shadows make the card look like a puffed lump of clay.

<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">Hayun Jung</p>
<p class="cp__role">Illustrator</p>
</div>
</div>
<div class="cp__stats">
<div class="cp__stat"><strong class="cp__num">5,108</strong><span class="cp__lab">Followers</span></div>
<div class="cp__stat"><strong class="cp__num">94</strong><span class="cp__lab">Posts</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">Follow</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;
}
}// 실제 동작: Follow 버튼 토글. 첫 조작에 자동 루프가 끊긴다.
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 ? 'Following' : 'Follow';
});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 · Claymorphism — Measured area 1.766% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The squishy volume comes from one shadow spreading outward, a bright inner highlight, and a dark inner hollow.
.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); }
}
04Minimal line
What you see
A white card on a white page has no background contrast to work with.

<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">Yeonwoo Choi<span class="ml__line ml__line--name" aria-hidden="true"></span></h2>
<p class="ml__role">Brand strategist</p>
<p class="ml__stats">
<span class="ml__stat"><strong>812</strong>Followers</span>
<span class="ml__stat"><strong>41</strong>Posts</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">Follow</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;
}
}// 실제 동작: Follow 토글(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 ? 'Follow' : 'Following';
});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 · Minimal line — Measured area 0.215% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Hairlines and whitespace carry the shape, and the only ornament is a 2px rule that draws itself from the left, first under the name and then along the bottom of the button.
.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); }
05Spinning gradient border
What happens on screen
A conic gradient sweeps around the card and an inner panel covers it, leaving a 4px rim of light.

<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">Jiun Han</h2>
<p class="gr__role">Motion designer</p>
<p class="gr__stats">
<span class="gr__stat"><strong>9,640</strong>Followers</span>
<span class="gr__stat"><strong>210</strong>Posts</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">Follow</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;
}
}// 실제 동작: Follow 토글(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 ? 'Follow' : 'Following';
});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 · Spinning gradient border — Measured area 2.851% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Registering the angle as a typed custom property is the whole trick.
@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; } }
06Dark glow ring
What moves
On a dark card, a glow around the avatar ring breathes brighter and dimmer.

<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">Minjae Seo</h2>
<p class="dg__role">Sound engineer</p>
<p class="dg__stats">
<span class="dg__stat"><strong>3,325</strong>Followers</span>
<span class="dg__stat"><strong>57</strong>Posts</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">Follow</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;
}
}// 실제 동작: Follow 토글(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 ? 'Follow' : 'Following';
});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 · Dark glow ring — Measured area 5.456% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Boxes get box-shadow inside and out, while the initials get a drop-shadow that follows the glyph outline instead of a rectangle.
.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 flip contact
What you see
The card turns half a revolution around its vertical axis, so the intro on the front becomes contact details on the back.

<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">Sea Oh</p>
<p class="fc__role">UX researcher</p>
<p class="fc__stats"><span class="fc__num">1,204</span> Followers · <span class="fc__num">62</span> Posts</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>Follow</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>Following</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>View contact
</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">Email</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">Phone</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">Website</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>View front
</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;
}
}// 실제 동작: View contact/앞면 보기가 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 flip contact — Measured area 43.991% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Both faces have to hide their own back side, or the text on the far face shows through mirrored.
.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); }
08Social stat count-up
What happens on screen
Three numbers climb from zero to their real values one frame at a time.

<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">Taeo Yoon</span>
<span class="pc__role">Data analyst</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">Followers</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">Posts</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">Likes</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>Follow</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>Following</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자리 콤마).
// 팔로우를 누르면 Followers 값이 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 · Social stat count-up — Measured area 2.694% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Pressing follow moves the follower target by one and re-counts that number alone, with a run id so an older count cannot keep writing over the new one.
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);
}
09Team grid expand
What moves
When one tile widens, the role and follow button rise from below while the other two narrow over the same beat, so the row keeps its total width.

<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>
Team members
</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">Juha Kang</span>
<span class="tg__role">Team lead</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>Follow</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>Following</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">Eunseol Noh</span>
<span class="tg__role">Product designer</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>Follow</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>Following</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">Gaon Im</span>
<span class="tg__role">Front-end dev</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>Follow</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>Following</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 · Team grid expand — Measured area 22.797% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Moving focus with the keyboard has to suppress scrolling.
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 });
});
Where it breaks — the trap
The trap in this set was not a style. It was the thumbnail. The poster used in the grid and in share cards comes from sampling two seconds into 24 frames and picking the one that differs most from the frame before it, and for 07 that was always the card seen exactly edge-on: a single white line on an orange field. It is not bad luck. The on-screen width of a face rotated around its vertical axis is the cosine of the angle, so its rate of change is the sine, which peaks at 90 degrees. Linear or eased, the frame that changed most is the edge.
Three more items had the same disease. Fading in the three stat blocks of 08 and the role and button of 09 with opacity produced a half-transparent ghost as the poster, and the rule in 04 was drawn at a constant rate, so the stub right after the start got picked and read like a typo. The cure was the same idea in all three: kill the stretch where the change creeps along gradually so the finished state lands inside a single sample. In 08 and 09 that state is swapped in one frame with steps(1, end), lined up with the finished state; in 04, where the line still has to be drawn, the last part of the rule was packed into one sample interval of 83.3 milliseconds, so the frame where the line reaches full width is the biggest change. For 07 only the preview loop became a cut; a real click still flips through a smooth transition. Cutting also dropped the changed-pixel area from 56.2% to 44.0% because the sweep across the stage disappeared, so small opacity-free motion went onto both faces and lifted moving frames from 10 to 18. A cut on its own only moves two frames, which measures as no animation at all.
Delayed animations cost one more round. Give an element animation-delay without animation-fill-mode: backwards and it renders in its authored state for the whole delay. All three stat blocks in 08 were visible in the first frame, went dark, then came back in sequence. During the delay the animation has not been applied yet, and the frame grabber records that moment as it is. Writing the stagger with the animation shorthand has the same flavor of bug, since the shorthand resets the delay to zero: every staggered item here — 03, 04, 06, 07, 08, and 09 — writes the delay as a longhand property.
Accessibility (reduced-motion)
Color contrast was calculated on the composited colors rather than guessed. Under a translucent panel the real background is whatever the blend produces, so white text can look fine and still fail.
| Item | Text / background | Ratio | Verdict |
|---|---|---|---|
| 01 glass | ink / frost panel (255, 130, 98) | 7.49:1 | pass |
| 01 glass (rejected) | white / same frost panel | 2.44:1 | fail |
| 02 neo-brutalist | white / blue pill | 4.53:1 | pass |
| 03 clay | ink at 62% / pink card | 4.79:1 | pass |
| 04 minimal | ink at 64% / white card | 5.44:1 | pass |
| 06 dark | mint status dot / dark panel | 8.64:1 | pass |
| 09 team | cream / expanded tile | 10.99:1 | pass |
Item 01 started out with white text at 2.44:1, which is below the bar. Making the frost panel whiter and switching the text to ink took it to 7.49:1. The secondary text in 04 was also short at 4.04:1 with 55% opacity, so it went up to 64%. Engraving numbers onto a card can borrow the coordinate math from CSS Chart Design as is. With the contrast fixed, all nine sources live in a zip that opens with the archive password kx4qjk9q, and the vanilla and React builds use the same values.
Under prefers-reduced-motion: reduce all nine drop their CSS autoplay loop. The blobs in 01 stand still, 02 keeps only the real :active press instead of the repeat, the avatar in 03 rests at its normal size, the halo in 06 settles at its dim base opacity while the ring and its glow stay lit, and the rim in 05 stops turning while the gradient stays visible. Item 07 loses the flip transition and shows the back instantly on click, 08 drops the staggered entrance of its stat blocks and bars, and 09 changes state without the widening transition. The count-up in 08 is the one piece of motion written in JavaScript rather than CSS, so the media query does not reach it on its own: both the vanilla and React builds read matchMedia('(prefers-reduced-motion: reduce)') once and write the final values instead of counting. In every case the state itself — followed or not, expanded or not — survives. The 3D properties behind 07 are documented in the MDN page on transform-style.
For more cards in the same family, see 9 CSS card hover effects and 9 login form design patterns.
FAQ
Can a profile card design css use initials instead of an avatar image?
They are. All nine fill a circle with a CSS color or gradient and drop two initials in it, with no image file anywhere. On an account that has no photo yet this hides the empty slot better than a gray silhouette does, and it adds zero requests. When a photo arrives later, cover the inside of the circle with a background-image and leave the outer size and border exactly as they are. The layout where a profile card becomes the first piece on screen is covered whole in Hero Section Design.
I added backdrop-filter and nothing blurs
Nothing is behind it to blur. backdrop-filter works on what sits behind the element rather than the element itself, so over a flat background you get a blurred flat color and see no difference at all. Something has to be back there, like the drifting blobs in 01 or a photo. A fully opaque card background hides the result just as completely, so keep the alpha below one.
My gradient border refuses to spin
The @property registration is probably missing. A plain custom property is treated as a string, so the browser cannot interpolate between two values and the angle jumps from 0 to 360 degrees in one go, which looks frozen. Declaring syntax: "<angle>" with an initial-value the way 05 does is what makes the angle animate continuously.









