08소셜 스탯 카운트업
팔로워·게시물·좋아요 숫자가 0에서 실제 값까지 프레임마다 올라가고, 자릿수가 바뀌어도 칸이 흔들리지 않게 고정폭 숫자를 씁니다.
쓰임크리에이터 소개·대시보드 머리처럼 숫자가 그 사람의 실적인 화면
requestAnimationFrametabular-numsaria-live

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


