09통계 카운터 — 목표 값까지 프레임마다 계산해 올라가는 숫자
섹션이 뷰포트에 들어온 상태가 되면 숫자가 0에서 목표 값까지 매 프레임 계산되어 올라가고, 자릿수가 늘어나는 값에서는 새 자리가 함께 나타납니다.
쓰임실적·수치 강조 섹션, 신뢰 지표 나열
requestAnimationFrameMath.powtoLocaleString

<main class="stage" id="stage">
<section class="stats" id="stats">
<div class="stat">
<p class="stat__num" id="stat-1" data-target="12800" data-suffix="+">0</p>
<p class="stat__label">누적 사용자</p>
</div>
<div class="stat">
<p class="stat__num" id="stat-2" data-target="98" data-suffix="%">0</p>
<p class="stat__label">만족도</p>
</div>
<div class="stat">
<p class="stat__num" id="stat-3" data-target="42">0</p>
<p class="stat__label">연동 서비스</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$color: $subject-blue-light;
@include stage($stage-ink);
.stats {
display: flex;
gap: $sp-8;
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
.stat__num {
margin: 0;
color: $color;
font-weight: 800;
font-size: clamp(24px, 8vmin, 34px);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.stat__label {
margin: $sp-2 0 0;
color: $stage-paper;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 340px) {
.stats { gap: $sp-5; }
}
// 카운트업 자체는 JS(rAF)가 matchMedia 로 직접 분기 — 여기는 CSS 트랜지션이 없어 no-op.
@media (prefers-reduced-motion: reduce) {
.stat__num { animation: none; 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);
}
.stats {
display: flex;
gap: 32px;
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
.stat__num {
margin: 0;
color: #5b8cf8;
font-weight: 800;
font-size: clamp(24px, 8vmin, 34px);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.stat__label {
margin: 8px 0 0;
color: #ffffff;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 340px) {
.stats {
gap: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.stat__num {
animation: none;
transition: none;
}
}// 화면에 보이는 순간부터 0에서 목표 값까지, 매 프레임(rAF) 다시 계산해 textContent 를 갱신한다.
// 계산 겹: 경과 시간 비율 -> 완화 함수 -> 현재 값(0~목표) -> 문자열 표시. 자동 재생(리스너 없음).
(function () {
var COUNT_DURATION_MS = 1700;
var nums = document.querySelectorAll('.stat__num');
var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function format(n) {
return Math.round(n).toLocaleString('ko-KR');
}
function run(el) {
var target = Number(el.getAttribute('data-target')) || 0;
var suffix = el.getAttribute('data-suffix') || '';
if (reduceMotion) {
el.textContent = format(target) + suffix;
return;
}
var start = null;
function step(ts) {
if (start === null) start = ts;
var elapsed = ts - start;
var t = Math.min(1, elapsed / COUNT_DURATION_MS);
var value = target * easeOutCubic(t);
el.textContent = format(value) + suffix;
if (t < 1) {
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
}
nums.forEach(run);
})();import { useEffect, useRef } from "react";
import styles from "./StatsCounter.module.scss";
// 화면에 보이는 순간부터 0에서 목표 값까지, 매 프레임(rAF) 다시 계산해 textContent 를 갱신한다.
// 계산 겹: 경과 시간 비율 -> 완화 함수 -> 현재 값(0~목표) -> 문자열 표시. 자동 재생(리스너 없음).
const COUNT_DURATION_MS = 1700;
const STATS = [
{ id: "stat-1", target: 12800, suffix: "+", label: "누적 사용자" },
{ id: "stat-2", target: 98, suffix: "%", label: "만족도" },
{ id: "stat-3", target: 42, suffix: "", label: "연동 서비스" },
];
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3);
}
function format(n: number) {
return Math.round(n).toLocaleString("ko-KR");
}
export default function StatsCounter() {
const numRefs = useRef<Array<HTMLParagraphElement | null>>([]);
useEffect(() => {
const reduceMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rafIds: number[] = [];
const refs = numRefs.current!;
STATS.forEach((stat, i) => {
const el = refs[i];
if (!el) return;
if (reduceMotion) {
el.textContent = format(stat.target) + stat.suffix;
return;
}
let start: number | null = null;
function step(ts: number) {
if (start === null) start = ts;
const elapsed = ts - start;
const t = Math.min(1, elapsed / COUNT_DURATION_MS);
const value = stat.target * easeOutCubic(t);
el!.textContent = format(value) + stat.suffix;
if (t < 1) {
rafIds[i] = window.requestAnimationFrame(step);
}
}
rafIds[i] = window.requestAnimationFrame(step);
});
return () => rafIds.forEach((id) => window.cancelAnimationFrame(id));
}, []);
return (
<main className={styles.stage}>
<section className={styles.stats}>
{STATS.map((stat, i) => (
<div key={stat.id} className={styles.stat}>
<p
className={styles.stat__num}
ref={(node: HTMLParagraphElement | null) => {
numRefs.current![i] = node;
}}
>
0
</p>
<p className={styles.stat__label}>{stat.label}</p>
</div>
))}
</section>
</main>
);
}09 통계 카운터 — 목표 값까지 프레임마다 계산해 올라가는 숫자 — 측정 누적면적 3.044% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 랜딩페이지 히어로섹션 디자인 9종 — 통계 카운터까지
이 데모가 속한 글
랜딩페이지 히어로섹션 디자인 9종 — 통계 카운터까지랜딩페이지 히어로섹션 아홉 개 중 하나는 카드가 보이는 비율 값이 기준을 넘을 때마다 순서 값에 간격을 곱해 나타나는 시점을 다시 구하고, 하나는 열린 질문의 답변 높이 값을 내용 길이로 다시 구해 펼치며, 또 하나 이 글의 데모 전부 보기


