05Team stat row — a count-up that runs elapsed-time ratio through an easing function
Dividing the elapsed time requestAnimationFrame hands over each frame by the total duration makes a 0-to-1 ratio, running that ratio through an easing function and multiplying the result by the target number recomputes the displayed integer every single frame, and performance.now fires the whole thing exactly once when the row enters view.
Best forYears-founded and project-count highlights, company-scale intros
requestAnimationFrameeasing functionperformance.now

<main class="stage">
<section class="stat" id="stat">
<p class="stat__hint" id="statHint">The numbers count up by calculating easing every frame with rAF, and when the duration changes, the same formula runs again at a different speed.</p>
<div class="stat__row" id="statRow" role="list" aria-label="Team metrics">
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-0" data-target="48">0</span><span class="stat__label">Members</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-1" data-target="126">0</span><span class="stat__label">Projects</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-2" data-target="9">0</span><span class="stat__label">Offices</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-3" data-target="97">0</span><span class="stat__label">Satisfaction%</span></div>
</div>
<p class="stat__meta"><span class="stat__chip" id="statChip">duration 1600ms</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 600ms 대역 — 표시 값은 JS 상수 1600ms/800ms 가 실제 지속시간
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.stat {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: $sp-3;
align-items: center;
text-align: center;
}
.stat__hint {
margin: 0;
padding: 0 $sp-2;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.stat__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-2;
width: 100%;
}
.stat__cell {
display: flex;
flex-direction: column;
gap: $sp-1;
align-items: center;
}
.stat__num {
font-weight: 700;
font-size: 22px;
line-height: 1;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.stat__label {
font-weight: 500;
font-size: 9px;
color: rgba($subject-cream, .72);
}
.stat__meta { margin: 0; }
.stat__chip {
display: inline-flex;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.stat { max-width: 300px; gap: $sp-2; }
.stat__hint { font-size: 9px; }
.stat__row { gap: $sp-1; }
.stat__num { font-size: 18px; }
.stat__label { font-size: 8px; }
.stat__chip { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.stat__num {
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);
}
.stat {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
text-align: center;
}
.stat__hint {
margin: 0;
padding: 0 8px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.stat__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 100%;
}
.stat__cell {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.stat__num {
font-weight: 700;
font-size: 22px;
line-height: 1;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.stat__label {
font-weight: 500;
font-size: 9px;
color: rgba(255, 247, 230, 0.72);
}
.stat__meta {
margin: 0;
}
.stat__chip {
display: inline-flex;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.stat {
max-width: 300px;
gap: 8px;
}
.stat__hint {
font-size: 9px;
}
.stat__row {
gap: 4px;
}
.stat__num {
font-size: 18px;
}
.stat__label {
font-size: 8px;
}
.stat__chip {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.stat__num {
transition: none;
}
}// 팀 스탯 카운트업(05) — 목표값까지 매 rAF 프레임마다 진행률(t)에 easeOutCubic 을 적용해
// 그 순간의 표시값을 계산한다(계산 한 겹). duration 변형(1600ms↔800ms)은 같은 공식의
// 입력 하나만 바뀌는 것을 보여주려고 자동 시연에서 번갈아 돈다.
(function () {
var row = document.getElementById('statRow');
var chip = document.getElementById('statChip');
if (!row || !chip) return;
var nums = Array.prototype.slice.call(row.querySelectorAll('.stat__num'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var DEFAULT_MS = 1600; // 기본 지속시간
var DURATIONS = [1600, 800];
var durIdx = 0;
var raf = null;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function runOnce(ms) {
if (raf !== null) cancelAnimationFrame(raf);
var start = performance.now();
var targets = nums.map(function (el) { return parseInt(el.getAttribute('data-target'), 10) || 0; });
function step(now) {
var t = Math.min(1, (now - start) / ms);
var eased = easeOutCubic(t);
nums.forEach(function (el, i) {
el.textContent = String(Math.round(targets[i] * eased));
});
if (t < 1) {
raf = requestAnimationFrame(step);
} else {
raf = null;
}
}
nums.forEach(function (el) { el.textContent = '0'; });
raf = requestAnimationFrame(step);
}
function cycle() {
var ms = DURATIONS[durIdx];
chip.textContent = 'duration ' + ms + 'ms';
runOnce(ms);
}
cycle();
if (!reduce) {
setInterval(function () {
durIdx = (durIdx + 1) % DURATIONS.length;
cycle();
}, 1100);
}
})();// TeamStatRow — vanilla 판(run/338/demo/05-team-stat-row/index.html) 의 React 포트.
// 숫자를 rAF 로 매 프레임 이징을 계산해 올리며, 지속시간이 바뀌면 같은 공식이 다른
// 속도로 다시 돈다(계산 한 겹).
import { useEffect, useRef } from "react";
import styles from "./TeamStatRow.module.scss";
export type TeamStatRowProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "숫자는 rAF 로 매 프레임 이징을 계산해 올라가며, 지속시간이 바뀌면 같은 공식이 다른 속도로 다시 돕니다";
const LIST_LABEL = "팀 지표";
type Stat = { target: number; label: string };
const STATS: Stat[] = [
{ target: 48, label: "팀원" },
{ target: 126, label: "프로젝트" },
{ target: 9, label: "지사" },
{ target: 97, label: "만족도%" }
];
const DURATIONS = [1600, 800];
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
type Box<T> = { current: T };
export default function TeamStatRow({
duration = "600ms",
easing = "ease-out",
color = "#0a4fe8"
}: TeamStatRowProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRef = useRef<HTMLSpanElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const row = rowRef.current;
const chip = chipRef.current;
if (!row || !chip) return;
const nums = Array.prototype.slice.call(row.querySelectorAll("." + styles.num)) as HTMLElement[];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function runOnce(ms: number): void {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
const targets = nums.map((el, i) => STATS[i] ? STATS[i].target : 0);
function step(now: number): void {
const t = Math.min(1, (now - start) / ms);
const eased = easeOutCubic(t);
nums.forEach((el, i) => {
el.textContent = String(Math.round(targets[i] * eased));
});
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
}
}
rafRef.current = requestAnimationFrame(step);
}
let idx = 0;
function cycle(ms: number): void {
chip!.textContent = "duration " + ms + "ms";
runOnce(ms);
}
cycle(DURATIONS[0]);
let timer = 0;
if (!reduce) {
timer = window.setInterval(() => {
idx = (idx + 1) % DURATIONS.length;
cycle(DURATIONS[idx]);
}, 1100);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.stat}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row} ref={rowRef} role="list" aria-label={LIST_LABEL}>
{STATS.map((s, i) => (
<div className={styles.cell} role="listitem" key={i}>
<span className={styles.num}>0</span>
<span className={styles.label}>{s.label}</span>
</div>
))}
</div>
<p className={styles.meta}>
<span className={styles.chip} ref={chipRef} style={{ color }}>duration 1600ms</span>
</p>
</section>
</main>
);
}05 Team stat row — a count-up that runs elapsed-time ratio through an easing function — Measured area 1.517% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 Team Page Design Patterns (Copy-Paste)
The post this demo lives in
9 Team Page Design Patterns (Copy-Paste)Nine team-page sections that compute their layout: columns balance by headcount, bios get read-more only past overflow, See all demos in this post
If you like this effect - similar demos
Landing sections08 Social stat count-up9 Profile Card Design Styles, Copy-Paste Ready
Landing sections01 Plan cards — a card row that recomputes its price on every billing switch9 Pricing Page Design Sections (Copy-Paste)
Landing sections01 Case hero — a top section that recomputes its headline metric every frame to count it up9 Case Study Page Design Sections (Copy-Paste)