05팀 스탯 줄 — 경과 시간 비율을 이징 함수에 넣어 세는 카운트업
requestAnimationFrame 이 매 프레임 전달하는 경과 시간을 총 지속시간으로 나눠 0~1 비율을 만들고, 이 비율을 이징 함수에 통과시켜 나온 값에 목표 숫자를 곱해 표시할 정수를 매 프레임 다시 계산합니다. 로드 직후 performance.now 기준으로 시작하고, 이후 값은 전부 이 한 함수의 반복 호출로 나옵니다.
쓰임창립 연차·프로젝트 수 강조, 회사 규모 소개
requestAnimationFrameeasing functionperformance.now

<main class="stage">
<section class="stat" id="stat">
<p class="stat__hint" id="statHint">숫자는 rAF 로 매 프레임 이징을 계산해 올라가며, 지속시간이 바뀌면 같은 공식이 다른 속도로 다시 돕니다</p>
<div class="stat__row" id="statRow" role="list" aria-label="팀 지표">
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-0" data-target="48">0</span><span class="stat__label">팀원</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-1" data-target="126">0</span><span class="stat__label">프로젝트</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-2" data-target="9">0</span><span class="stat__label">지사</span></div>
<div class="stat__cell" role="listitem"><span class="stat__num" id="stat-3" data-target="97">0</span><span class="stat__label">만족도%</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 팀 스탯 줄 — 경과 시간 비율을 이징 함수에 넣어 세는 카운트업 — 측정 누적면적 1.517% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 팀 소개 페이지 UI 9종 — 인원수·소개글 길이·시차까지 계산해서 보여주는
이 데모가 속한 글
팀 소개 페이지 UI 9종 — 인원수·소개글 길이·시차까지 계산해서 보여주는팀원이 몇 명이든 카드 줄이 어긋나지 않게 열 수를 계산하고, 소개글이 길면 재서 더보기를 붙이고, 파트너 로고는 개수와 무관하게 같은 속도로 흐르게 만드는 팀 소개 페이지 9종입니다. 이 글의 데모 전부 보기


