08Social stat count-up
Follower, post, and like counts climb from zero to their real values frame by frame, and tabular figures keep the columns from twitching as digits change.
Best forCreator intros and dashboard headers where the numbers are the person's track record
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">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 · 9 Profile Card Design Styles, Copy-Paste Ready
The post this demo lives in
9 Profile Card Design Styles, Copy-Paste ReadyProfile card design is the job of deciding what material a fixed block of content wears: avatar, name, role, stats, foll See all demos in this post
If you like this effect - similar demos
Landing sections05 Team stat row — a count-up that runs elapsed-time ratio through an easing function9 Team Page Design Patterns (Copy-Paste)
Landing sections04 Discount badge — a badge that works out its own percentage from list price and sale price9 Pricing Page Design Sections (Copy-Paste)
Landing sections05 Stat counter9 Media Page Templates — Copy-Paste