9 CSS Number Counter Animation — dán 1 dòng
CSS number counter animation là chữ số tự tăng từ 0 lên một giá trị đích bằng thuộc tính CSS, không cần JavaScript.
01 number-counter-count-up-property
02 number-counter-thousands-separator
03 number-counter-decimal-format
04 number-counter-currency-format
05 number-counter-percent-ring
06 number-counter-stat-cards-stagger
07 number-counter-on-view-start
08 number-counter-odometer-roll
09 number-counter-countdown-timer
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Đếm số nguyên bằng @property
- 02 Tự động chèn dấu phẩy hàng nghìn
- 03 Đếm số có định dạng thập phân
- 04 Đếm số tiền kèm ký hiệu tiền tệ
- 05 Vòng tròn phần trăm đồng bộ với số
- 06 3 thẻ thống kê đếm lần lượt
- 07 Chỉ bắt đầu khi cuộn vào tầm nhìn
- 08 Số cuộn kiểu đồng hồ odometer
- 09 Bộ đếm ngược thời gian
Chín cách đếm này xếp theo độ phức tạp của phép tính đứng sau con số, không theo mức phổ biến. 01 chỉ đếm một số nguyên. 02 tới 04 thêm định dạng — dấu phẩy hàng nghìn, số thập phân, ký hiệu tiền tệ. 05 và 06 gắn con số với một yếu tố khác — vòng tròn phần trăm, nhiều thẻ chạy lệch nhau. 07 tự quyết định khi nào bắt đầu đếm. 08 đếm bằng một cơ chế hoàn toàn khác — cuộn dải số thay vì đổi biến số. 09 đảo ngược hướng đếm về 0. Mỗi khối mã chỉ giữ phần thực sự bật animation, phần còn lại nằm trong file zip.
01Đếm số nguyên bằng @property
Bạn thấy gì
@property đăng ký --num làm một custom property kiểu số nguyên, rồi keyframes đẩy giá trị đó từ 0 lên 12,480 trong khi counter(num) vẽ đúng con số ấy thành chữ số thật trên màn hình.

<main class="stage">
<div class="counter is-demo">
<span class="counter__num" aria-hidden="true"></span>
<span class="counter__sr">12,480</span>
<span class="counter__label">Người đăng ký</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-loop; // 0에서 목표까지 세는 시간
$easing: $ease-out;
$color: $subject-cream; // 숫자 색
@include stage($stage-ink);
// ── @property: 정수 커스텀 속성이라야 애니메이션 중간값도 정수로 보간된다 ──
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.counter {
text-align: center;
&__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800; font-size: clamp(36px, 11vw, 60px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums; // 자릿수가 늘어도 숫자 폭이 흔들리지 않는다
color: $color;
&::before { content: counter(num); }
}
// counter() 생성 콘텐츠는 스크린 리더가 못 읽는다 — 실제 최종값을 시각적으로만 숨겨 따로 둔다
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: rgba(255, 255, 255, .6);
}
}
.counter.is-demo .counter__num {
animation: count-up $duration $easing infinite;
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 12480; }
100% { --num: 12480; }
}
// ── 접근성: 움직임 줄이기 설정이면 최종값에 즉시 멈춘다 ──────────
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num { animation: none; --num: 12480; }
}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);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.counter {
text-align: center;
}
.counter__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(36px, 11vw, 60px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.counter__num::before {
content: counter(num);
}
.counter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.counter__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, 0.6);
}
.counter.is-demo .counter__num {
animation: count-up 2s ease-out infinite;
}
@keyframes count-up {
0% {
--num: 0;
}
65% {
--num: 12480;
}
100% {
--num: 12480;
}
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num {
animation: none;
--num: 12480;
}
}import type { CSSProperties } from "react";
import styles from "./CountUpProperty.module.scss";
export type CountUpPropertyProps = {
value: number;
label: string;
/** 0에서 목표값까지 세는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--num-target"?: number };
export default function CountUpProperty({ value, label, duration = 2000, easing = "ease-out", color = "#fff7e6" }: CountUpPropertyProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--num-target": value,
};
return (
<div className={styles.counter} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{value.toLocaleString()}</span>
<span className={styles.label}>{label}</span>
</div>
);
}01 · Đếm số nguyên bằng @property — Diện tích đo được 3.905% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Cách này hợp với thẻ thống kê người đăng ký, lượt truy cập hay tổng số thành viên.
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 2s ease-out infinite;
&::before { content: counter(num); }
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 12480; }
100% { --num: 12480; }
}
02Tự động chèn dấu phẩy hàng nghìn
Chuyện gì diễn ra trên màn hình
Phần nghìn và phần dư được đếm bằng hai custom property @property tách riêng, còn @counter-style với pad: 3 '0' luôn giữ phần dư đủ ba chữ số để tránh ra kết quả sai như "12,3".

<main class="stage">
<div class="counter is-demo">
<span class="counter__num" aria-hidden="true"></span>
<span class="counter__sr">12,483</span>
<span class="counter__label">Lượt xem</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink; // 숫자 색 (노란 무대 위 대비)
@include stage($stage-yellow);
// 천 단위(k)와 나머지(n)를 각각 다른 정수 커스텀 속성으로 나눠 센다
@property --k {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
@property --n {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
// 나머지(n)는 항상 세 자리로 채워야 "12,003" 이 "12,3" 이 되지 않는다
@counter-style padded-3 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
suffix: '';
pad: 3 '0';
}
.counter {
text-align: center;
&__num {
--k: 0;
--n: 0;
counter-reset: k var(--k) n var(--n);
display: block;
font-weight: 800; font-size: clamp(30px, 9vw, 52px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(k) "," counter(n, padded-3); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: rgba($subject-ink, .72); // 흐린 글자는 알파 .72 하한 — 옐로 면 위 6.06:1
}
}
.counter.is-demo .counter__num {
animation: thousands-up $duration $easing infinite;
}
@keyframes thousands-up {
0% { --k: 0; --n: 0; }
65% { --k: 12; --n: 483; }
100% { --k: 12; --n: 483; }
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num { animation: none; --k: 12; --n: 483; }
}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);
}
@property --k {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@property --n {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@counter-style padded-3 {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
suffix: "";
pad: 3 "0";
}
.counter {
text-align: center;
}
.counter__num {
--k: 0;
--n: 0;
counter-reset: k var(--k) n var(--n);
display: block;
font-weight: 800;
font-size: clamp(30px, 9vw, 52px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.counter__num::before {
content: counter(k) "," counter(n, padded-3);
}
.counter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.counter__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.counter.is-demo .counter__num {
animation: thousands-up 2s ease-out infinite;
}
@keyframes thousands-up {
0% {
--k: 0;
--n: 0;
}
65% {
--k: 12;
--n: 483;
}
100% {
--k: 12;
--n: 483;
}
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num {
animation: none;
--k: 12;
--n: 483;
}
}import type { CSSProperties } from "react";
import styles from "./ThousandsSeparatorCounter.module.scss";
export type ThousandsSeparatorCounterProps = {
/** 12483 처럼 콤마 없는 정수 */
value: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--k-target"?: number; "--n-target"?: number;
};
export default function ThousandsSeparatorCounter({
value, label, duration = 2000, easing = "ease-out", color = "#17141a",
}: ThousandsSeparatorCounterProps) {
const k = Math.floor(value / 1000);
const n = value % 1000;
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--k-target": k,
"--n-target": n,
};
return (
<div className={styles.counter} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{value.toLocaleString()}</span>
<span className={styles.label}>{label}</span>
</div>
);
}02 · Tự động chèn dấu phẩy hàng nghìn — Diện tích đo được 2.662% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Định dạng này hợp với tổng doanh thu, lượt xem hay số lượng tồn kho lớn.
@counter-style padded-3 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 3 '0';
}
.num {
counter-reset: k var(--k) n var(--n);
&::before { content: counter(k) "," counter(n, padded-3); }
}
03Đếm số có định dạng thập phân
Chuyển động thế nào
Phần nguyên và phần thập phân chạy cùng lúc trên hai @property riêng, còn phần thập phân luôn được pad về đúng hai chữ số rồi nối sau dấu chấm.

<main class="stage">
<div class="counter is-demo">
<span class="counter__num" aria-hidden="true"></span>
<span class="counter__sr">4.98</span>
<span class="counter__label">Đánh giá trung bình</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
// 정수부(whole)와 소수부(frac)를 각각 다른 정수 커스텀 속성으로 함께 센다
@property --whole {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
@property --frac {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
// 소수부는 항상 두 자리 — 8이 ".8" 이 아니라 ".08" 로 보이게
@counter-style padded-2 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
suffix: '';
pad: 2 '0';
}
.counter {
text-align: center;
&__num {
--whole: 0;
--frac: 0;
counter-reset: whole var(--whole) frac var(--frac);
display: block;
font-weight: 800; font-size: clamp(36px, 11vw, 60px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(whole) "." counter(frac, padded-2); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: $subject-ink; // 주황 면 위 크림 글자는 3.11:1 — 잉크로 5.50:1
}
}
.counter.is-demo .counter__num {
animation: decimal-up $duration $easing infinite;
}
@keyframes decimal-up {
0% { --whole: 0; --frac: 0; }
65% { --whole: 4; --frac: 98; }
100% { --whole: 4; --frac: 98; }
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num { animation: none; --whole: 4; --frac: 98; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --whole {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@property --frac {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
@counter-style padded-2 {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
suffix: "";
pad: 2 "0";
}
.counter {
text-align: center;
}
.counter__num {
--whole: 0;
--frac: 0;
counter-reset: whole var(--whole) frac var(--frac);
display: block;
font-weight: 800;
font-size: clamp(36px, 11vw, 60px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.counter__num::before {
content: counter(whole) "." counter(frac, padded-2);
}
.counter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.counter__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.counter.is-demo .counter__num {
animation: decimal-up 2s ease-out infinite;
}
@keyframes decimal-up {
0% {
--whole: 0;
--frac: 0;
}
65% {
--whole: 4;
--frac: 98;
}
100% {
--whole: 4;
--frac: 98;
}
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num {
animation: none;
--whole: 4;
--frac: 98;
}
}import type { CSSProperties } from "react";
import styles from "./DecimalFormatCounter.module.scss";
export type DecimalFormatCounterProps = {
/** 4.98 처럼 소수 둘째 자리까지 */
value: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--whole-target"?: number; "--frac-target"?: number;
};
export default function DecimalFormatCounter({
value, label, duration = 2000, easing = "ease-out", color = "#fff7e6",
}: DecimalFormatCounterProps) {
const whole = Math.floor(value);
const frac = Math.round((value - whole) * 100);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--whole-target": whole,
"--frac-target": frac,
};
return (
<div className={styles.counter} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{value.toFixed(2)}</span>
<span className={styles.label}>{label}</span>
</div>
);
}03 · Đếm số có định dạng thập phân — Diện tích đo được 2.37% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Kiểu đếm này hợp với điểm đánh giá, số sao trung bình hay chỉ số nhiệt độ.
@counter-style padded-2 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 2 '0';
}
.num {
counter-reset: whole var(--whole) frac var(--frac);
&::before { content: counter(whole) "." counter(frac, padded-2); }
}
04Đếm số tiền kèm ký hiệu tiền tệ
Bạn thấy gì
Ký hiệu tiền tệ được gắn cố định bằng ::before, còn ::after chỉ vẽ riêng counter(num) đang đếm, nên ký hiệu không bị đẩy đi khi số chữ số tăng từ một lên bốn.

<main class="stage">
<div class="counter is-demo">
<span class="counter__num" aria-hidden="true"></span>
<span class="counter__sr">$248</span>
<span class="counter__label">Số tiền thanh toán</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.counter {
text-align: left; // 자릿수가 늘어도 통화 기호가 같은 자리를 지킨다
&__num {
--num: 0;
counter-reset: num var(--num);
display: inline-flex;
align-items: baseline;
font-weight: 800; font-size: clamp(34px, 10vw, 56px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
// 통화 기호는 고정 텍스트 — 숫자만 애니메이션한다
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: rgba(23, 20, 26, .6);
}
}
.counter.is-demo .counter__num {
animation: currency-up $duration $easing infinite;
}
@keyframes currency-up {
0% { --num: 0; }
55% { --num: 248; }
100% { --num: 248; }
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num { animation: none; --num: 248; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.counter {
text-align: left;
}
.counter__num {
--num: 0;
counter-reset: num var(--num);
display: inline-flex;
align-items: baseline;
font-weight: 800;
font-size: clamp(34px, 10vw, 56px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #2f6df6;
}
.counter__num::before {
content: "$";
margin-right: 2px;
}
.counter__num::after {
content: counter(num);
}
.counter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.counter__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.counter.is-demo .counter__num {
animation: currency-up 600ms ease-out infinite;
}
@keyframes currency-up {
0% {
--num: 0;
}
55% {
--num: 248;
}
100% {
--num: 248;
}
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo .counter__num {
animation: none;
--num: 248;
}
}import type { CSSProperties } from "react";
import styles from "./CurrencyFormatCounter.module.scss";
export type CurrencyFormatCounterProps = {
value: number;
label: string;
symbol?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--num-target"?: number };
export default function CurrencyFormatCounter({
value, label, symbol = "$", duration = 600, easing = "ease-out", color = "#2f6df6",
}: CurrencyFormatCounterProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--num-target": value,
};
return (
<div className={styles.counter} style={vars}>
<span className={styles.num} aria-hidden="true">
<span className={styles.symbol}>{symbol}</span>
</span>
<span className={styles.sr}>{symbol}{value.toLocaleString()}</span>
<span className={styles.label}>{label}</span>
</div>
);
}04 · Đếm số tiền kèm ký hiệu tiền tệ — Diện tích đo được 3.635% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Cách gắn này hợp với giá nổi bật, số tiền giảm giá hay tổng kết thanh toán.
.num {
counter-reset: num var(--num);
animation: count-up .6s ease-out infinite;
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
05Vòng tròn phần trăm đồng bộ với số
Chuyện gì diễn ra trên màn hình
Một @property số nguyên duy nhất vừa vẽ counter(p) thành chữ số vừa tính calc(var(--p) * 1%) cho góc tô của conic-gradient, nên vòng tròn và con số không bao giờ lệch nhau.

<main class="stage">
<div class="stat">
<div class="ring is-demo">
<span class="ring__num" aria-hidden="true"></span>
<span class="ring__sr">76%</span>
</div>
<span class="stat__label">Tỷ lệ đạt</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(8px, 2.4vw, 12px);
}
// 정수 하나(--p)가 링의 채움 각도와 숫자 텍스트를 동시에 구동한다
@property --p {
syntax: '<integer>';
initial-value: 0;
inherits: true; // 자식 .ring__num 이 같은 값을 counter-reset 으로 읽는다
}
.ring {
--p: 0;
position: relative;
width: clamp(84px, 22vw, 108px);
aspect-ratio: 1;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient($color calc(var(--p) * 1%), rgba(255, 255, 255, .16) 0);
&::before { // 도넛 구멍 — 무대 색과 같아 링처럼 보인다
content: "";
position: absolute;
inset: 15%;
border-radius: 50%;
background: $stage-ink;
}
&__num {
position: relative;
counter-reset: p var(--p);
font-weight: 800; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(p) "%"; }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
.stat__label {
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: rgba(255, 255, 255, .6);
}
.ring.is-demo {
animation: percent-up $duration $easing infinite;
}
@keyframes percent-up {
0% { --p: 0; }
65% { --p: 76; }
100% { --p: 76; }
}
@media (prefers-reduced-motion: reduce) {
.ring.is-demo { animation: none; --p: 76; }
}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 {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(8px, 2.4vw, 12px);
}
@property --p {
syntax: "<integer>";
initial-value: 0;
inherits: true;
}
.ring {
--p: 0;
position: relative;
width: clamp(84px, 22vw, 108px);
aspect-ratio: 1;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(#fff7e6 calc(var(--p) * 1%), rgba(255, 255, 255, 0.16) 0);
}
.ring::before {
content: "";
position: absolute;
inset: 15%;
border-radius: 50%;
background: #17141a;
}
.ring__num {
position: relative;
counter-reset: p var(--p);
font-weight: 800;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.ring__num::before {
content: counter(p) "%";
}
.ring__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.stat__label {
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, 0.6);
}
.ring.is-demo {
animation: percent-up 2s ease-out infinite;
}
@keyframes percent-up {
0% {
--p: 0;
}
65% {
--p: 76;
}
100% {
--p: 76;
}
}
@media (prefers-reduced-motion: reduce) {
.ring.is-demo {
animation: none;
--p: 76;
}
}import type { CSSProperties } from "react";
import styles from "./PercentRingCounter.module.scss";
export type PercentRingCounterProps = {
/** 0~100 */
percent: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--p-target"?: number };
export default function PercentRingCounter({
percent, label, duration = 2000, easing = "ease-out", color = "#fff7e6",
}: PercentRingCounterProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--p-target": percent,
};
return (
<div className={styles.stat}>
<div className={styles.ring} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{percent}%</span>
</div>
<span className={styles.label}>{label}</span>
</div>
);
}05 · Vòng tròn phần trăm đồng bộ với số — Diện tích đo được 2.993% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Đồng bộ này hợp với tiến độ, tỷ lệ đạt mục tiêu hay dung lượng lưu trữ đã dùng.
.ring {
--p: 0;
background: conic-gradient($color calc(var(--p) * 1%), transparent 0);
animation: fill-up 2s ease-out infinite;
}
.ring__num {
counter-reset: p var(--p);
&::before { content: counter(p) "%"; }
}
063 thẻ thống kê đếm lần lượt
Chuyển động thế nào
Ba thẻ không đếm cùng một nhịp — animation-delay lệch nhau 0,15 giây cho mỗi thẻ kế tiếp nên mắt có thứ tự để theo dõi thay vì phải nhìn ba con số nhảy cùng lúc.

<main class="stage">
<div class="cards is-demo">
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">356</span>
<span class="card__label">Thành viên</span>
</div>
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">82</span>
<span class="card__label">Doanh thu</span>
</div>
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">5</span>
<span class="card__label">Đánh giá</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.cards {
display: flex;
gap: clamp(10px, 3vw, 18px);
}
.card {
min-width: clamp(64px, 20vw, 88px);
padding: clamp(10px, 3vw, 16px) clamp(8px, 2.4vw, 12px);
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
text-align: center;
&__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800; font-size: clamp(20px, 6vw, 28px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(num); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: 4px;
font-weight: 600; font-size: clamp(10px, 3vw, 12px); line-height: 1; font-family: inherit;
color: rgba($subject-ink, .72); // 흐린 글자는 알파 .72 하한 — 카드 옐로 면 위 5.58:1
}
}
// 카드 세 장이 동시에 시작하지 않는다 — 순서대로 0.15초씩 늦게
.cards.is-demo .card:nth-child(1) .card__num { animation: count-a $duration $easing infinite; }
.cards.is-demo .card:nth-child(2) .card__num { animation: count-b $duration $easing infinite; animation-delay: .15s; }
.cards.is-demo .card:nth-child(3) .card__num { animation: count-c $duration $easing infinite; animation-delay: .3s; }
@keyframes count-a { 0% { --num: 0; } 55% { --num: 356; } 100% { --num: 356; } }
@keyframes count-b { 0% { --num: 0; } 55% { --num: 82; } 100% { --num: 82; } }
@keyframes count-c { 0% { --num: 0; } 55% { --num: 5; } 100% { --num: 5; } }
@media (prefers-reduced-motion: reduce) {
.cards.is-demo .card:nth-child(1) .card__num { animation: none; --num: 356; }
.cards.is-demo .card:nth-child(2) .card__num { animation: none; --num: 82; }
.cards.is-demo .card:nth-child(3) .card__num { animation: none; --num: 5; }
}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);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.cards {
display: flex;
gap: clamp(10px, 3vw, 18px);
}
.card {
min-width: clamp(64px, 20vw, 88px);
padding: clamp(10px, 3vw, 16px) clamp(8px, 2.4vw, 12px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
text-align: center;
}
.card__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(20px, 6vw, 28px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.card__num::before {
content: counter(num);
}
.card__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.card__label {
display: block;
margin-top: 4px;
font-weight: 600;
font-size: clamp(10px, 3vw, 12px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.cards.is-demo .card:nth-child(1) .card__num {
animation: count-a 2s ease-out infinite;
}
.cards.is-demo .card:nth-child(2) .card__num {
animation: count-b 2s ease-out infinite;
animation-delay: 0.15s;
}
.cards.is-demo .card:nth-child(3) .card__num {
animation: count-c 2s ease-out infinite;
animation-delay: 0.3s;
}
@keyframes count-a {
0% {
--num: 0;
}
55% {
--num: 356;
}
100% {
--num: 356;
}
}
@keyframes count-b {
0% {
--num: 0;
}
55% {
--num: 82;
}
100% {
--num: 82;
}
}
@keyframes count-c {
0% {
--num: 0;
}
55% {
--num: 5;
}
100% {
--num: 5;
}
}
@media (prefers-reduced-motion: reduce) {
.cards.is-demo .card:nth-child(1) .card__num {
animation: none;
--num: 356;
}
.cards.is-demo .card:nth-child(2) .card__num {
animation: none;
--num: 82;
}
.cards.is-demo .card:nth-child(3) .card__num {
animation: none;
--num: 5;
}
}import type { CSSProperties } from "react";
import styles from "./StatCardsStagger.module.scss";
export type Stat = { value: number; label: string };
export type StatCardsStaggerProps = {
stats: [Stat, Stat, Stat];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--num-target"?: number; "--delay"?: string;
};
export default function StatCardsStagger({
stats, duration = 2000, easing = "ease-out", color = "#17141a",
}: StatCardsStaggerProps) {
return (
<div className={styles.cards}>
{stats.map((s, i) => {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--num-target": s.value,
"--delay": `${i * 150}ms`, // 카드마다 0.15초씩 늦게 시작
};
return (
<div className={styles.card} key={s.label} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{s.value.toLocaleString()}</span>
<span className={styles.label}>{s.label}</span>
</div>
);
})}
</div>
);
}06 · 3 thẻ thống kê đếm lần lượt — Diện tích đo được 1.303% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Cách này hợp với thẻ tóm tắt dashboard hay ba số liệu nổi bật trên landing page.
.card:nth-child(1) .num {
animation: count-a 2s ease-out infinite;
}
.card:nth-child(2) .num {
animation: count-b 2s ease-out infinite;
animation-delay: .15s;
}
.card:nth-child(3) .num {
animation: count-c 2s ease-out infinite;
animation-delay: .3s;
}
07Chỉ bắt đầu khi cuộn vào tầm nhìn
Bạn thấy gì
Số đếm đứng yên ở 0 cho tới khi IntersectionObserver thấy thẻ vượt ngưỡng hiển thị 60%, lúc đó mới gắn class is-counting để animation-play-state chuyển từ paused sang running.

<main class="stage">
<div class="viewport is-demo">
<div class="track">
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">1,280</span>
<span class="card__label">Tổng lượt truy cập</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.viewport {
position: relative;
width: min(100%, 220px);
height: clamp(140px, 38vw, 170px);
overflow: hidden; // 뷰포트 밖 카드는 안 보인다 — 실제 화면 가장자리 역할
border-radius: $r-card;
background: rgba(255, 255, 255, .08);
}
.track {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
text-align: center;
&__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800; font-size: clamp(24px, 7vw, 34px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(num); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: 6px;
font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit;
color: $subject-ink; // 주황 면 위 크림 글자는 2.28:1 — 잉크로 5.90:1
}
// 실제 페이지: JS 가 붙인 .is-counting 이 있어야 카운트가 돈다 (뷰포트 밖이면 0에 멈춰 있다)
&__num { animation-play-state: paused; }
&.is-counting &__num { animation-play-state: running; }
}
// ── 데모(.is-demo): 카드가 스크롤하듯 아래→가운데→위로 지나가고, 가운데 있는 동안만 카운트가 돈다 ──
.viewport.is-demo .track {
animation: scroll-pass $duration $easing infinite;
}
.viewport.is-demo .card__num {
animation: count-when-visible $duration $easing infinite;
}
@keyframes scroll-pass {
0% { transform: translateY(64px); }
20%, 75% { transform: translateY(0); }
100% { transform: translateY(-64px); }
}
@keyframes count-when-visible {
0%, 20% { --num: 0; }
60% { --num: 1280; }
100% { --num: 1280; }
}
@media (prefers-reduced-motion: reduce) {
.viewport.is-demo .track { animation: none; transform: translateY(0); }
.viewport.is-demo .card__num { animation: none; --num: 1280; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.viewport {
position: relative;
width: min(100%, 220px);
height: clamp(140px, 38vw, 170px);
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
}
.track {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
text-align: center;
}
.card__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(24px, 7vw, 34px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.card__num::before {
content: counter(num);
}
.card__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.card__label {
display: block;
margin-top: 6px;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.card__num {
animation-play-state: paused;
}
.card.is-counting .card__num {
animation-play-state: running;
}
.viewport.is-demo .track {
animation: scroll-pass 2s ease-out infinite;
}
.viewport.is-demo .card__num {
animation: count-when-visible 2s ease-out infinite;
}
@keyframes scroll-pass {
0% {
transform: translateY(64px);
}
20%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(-64px);
}
}
@keyframes count-when-visible {
0%, 20% {
--num: 0;
}
60% {
--num: 1280;
}
100% {
--num: 1280;
}
}
@media (prefers-reduced-motion: reduce) {
.viewport.is-demo .track {
animation: none;
transform: translateY(0);
}
.viewport.is-demo .card__num {
animation: none;
--num: 1280;
}
}// 실제 페이지: 카드가 뷰포트에 60% 이상 들어와야 카운트를 시작한다 (스크롤해서 보이기 전엔 0)
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CounterOnViewStart.module.scss";
export type CounterOnViewStartProps = {
value: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--num-target"?: number };
export default function CounterOnViewStart({
value, label, duration = 1200, easing = "ease-out", color = "#fff7e6",
}: CounterOnViewStartProps) {
const ref = useRef<HTMLDivElement>(null);
const [counting, setCounting] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => setCounting(entries[0].isIntersecting),
{ threshold: .6 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--num-target": value,
};
return (
<div ref={ref} className={`${styles.card} ${counting ? styles.isCounting : ""}`} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{value.toLocaleString()}</span>
<span className={styles.label}>{label}</span>
</div>
);
}07 · Chỉ bắt đầu khi cuộn vào tầm nhìn — Diện tích đo được 6.182% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Kiểu trì hoãn này hợp với section thống kê trên landing page cuộn dài hay trang giới thiệu kiểu storytelling.
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);
// .num mặc định animation-play-state: paused
// chỉ .is-counting .num mới chuyển sang running
08Số cuộn kiểu đồng hồ odometer
Chuyện gì diễn ra trên màn hình
Mỗi hàng chữ số có một dải dọc 0 tới 9 riêng, cuộn bằng translateY đến đúng vị trí chữ số đích trong khi overflow: hidden trên ô cha chỉ để lộ đúng một hàng.

<main class="stage">
<div class="stat">
<div class="odometer is-demo" aria-hidden="true">
<div class="digit">
<div class="digit__strip"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></div>
</div>
<div class="digit">
<div class="digit__strip"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></div>
</div>
<div class="digit">
<div class="digit__strip"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></div>
</div>
</div>
<span class="odometer__sr">128</span>
<span class="stat__label">Điểm tích lũy</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(6px, 2vw, 10px);
}
.odometer {
display: flex;
gap: 3px;
}
$cell: clamp(30px, 9vw, 44px);
.digit {
width: clamp(20px, 6vw, 28px);
height: $cell;
overflow: hidden; // 스트립에서 지금 칸 하나만 보인다
border-radius: 4px;
background: rgba(23, 20, 26, .06);
&__strip {
transform: translateY(0); // 10칸 스트립이라 한 칸 = 10%. 값은 keyframes 가 직접 준다
span {
height: $cell;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
}
}
}
.odometer__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.stat__label {
font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit;
color: rgba(23, 20, 26, .6);
}
// 세 자리가 "1" "2" "8" 로 각각 다르게 굴러 "128" 이 된다
.odometer.is-demo .digit:nth-child(1) .digit__strip { animation: roll-1 $duration $easing infinite; }
.odometer.is-demo .digit:nth-child(2) .digit__strip { animation: roll-2 $duration $easing infinite; }
.odometer.is-demo .digit:nth-child(3) .digit__strip { animation: roll-8 $duration $easing infinite; }
// translateY 는 실수로 보간되는 표준 속성이라 스트립이 매 프레임 부드럽게 굴러간다
// (정수 커스텀 속성으로 자리를 옮기면 값이 0→1 처럼 한 계단만 있어 눈에 보이는 중간 프레임이 거의 없다)
@keyframes roll-1 { 0% { transform: translateY(0); } 60% { transform: translateY(-10%); } 100% { transform: translateY(-10%); } }
@keyframes roll-2 { 0% { transform: translateY(0); } 60% { transform: translateY(-20%); } 100% { transform: translateY(-20%); } }
@keyframes roll-8 { 0% { transform: translateY(0); } 60% { transform: translateY(-80%); } 100% { transform: translateY(-80%); } }
@media (prefers-reduced-motion: reduce) {
.odometer.is-demo .digit:nth-child(1) .digit__strip { animation: none; transform: translateY(-10%); }
.odometer.is-demo .digit:nth-child(2) .digit__strip { animation: none; transform: translateY(-20%); }
.odometer.is-demo .digit:nth-child(3) .digit__strip { animation: none; transform: translateY(-80%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(6px, 2vw, 10px);
}
.odometer {
display: flex;
gap: 3px;
}
.digit {
width: clamp(20px, 6vw, 28px);
height: clamp(30px, 9vw, 44px);
overflow: hidden;
border-radius: 4px;
background: rgba(23, 20, 26, 0.06);
}
.digit__strip {
transform: translateY(0);
}
.digit__strip span {
height: clamp(30px, 9vw, 44px);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #2f6df6;
}
.odometer__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.stat__label {
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.odometer.is-demo .digit:nth-child(1) .digit__strip {
animation: roll-1 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.odometer.is-demo .digit:nth-child(2) .digit__strip {
animation: roll-2 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.odometer.is-demo .digit:nth-child(3) .digit__strip {
animation: roll-8 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes roll-1 {
0% {
transform: translateY(0);
}
60% {
transform: translateY(-10%);
}
100% {
transform: translateY(-10%);
}
}
@keyframes roll-2 {
0% {
transform: translateY(0);
}
60% {
transform: translateY(-20%);
}
100% {
transform: translateY(-20%);
}
}
@keyframes roll-8 {
0% {
transform: translateY(0);
}
60% {
transform: translateY(-80%);
}
100% {
transform: translateY(-80%);
}
}
@media (prefers-reduced-motion: reduce) {
.odometer.is-demo .digit:nth-child(1) .digit__strip {
animation: none;
transform: translateY(-10%);
}
.odometer.is-demo .digit:nth-child(2) .digit__strip {
animation: none;
transform: translateY(-20%);
}
.odometer.is-demo .digit:nth-child(3) .digit__strip {
animation: none;
transform: translateY(-80%);
}
}import type { CSSProperties } from "react";
import styles from "./OdometerRoll.module.scss";
export type OdometerRollProps = {
/** 3자리 정수 (0~999) */
value: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--d-target"?: number };
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
export default function OdometerRoll({
value, label, duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6",
}: OdometerRollProps) {
const digits = String(value).padStart(3, "0").split("").map(Number);
return (
<div className={styles.stat}>
<div className={styles.odometer} aria-hidden="true">
{digits.map((d, i) => {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--d-target": d,
};
return (
<div className={styles.digit} key={i}>
<div className={styles.strip} style={vars}>
{DIGITS.map((n) => <span key={n}>{n}</span>)}
</div>
</div>
);
})}
</div>
<span className={styles.sr}>{value}</span>
<span className={styles.label}>{label}</span>
</div>
);
}08 · Số cuộn kiểu đồng hồ odometer — Diện tích đo được 0.96% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Ba cột cuộn độc lập ghép thành "128", hợp với bộ đếm phong cách retro, bảng điểm game hay số thứ tự chờ ở kiosk.
.digit {
height: 44px;
overflow: hidden; // chỉ một hàng trong dải được lộ ra
}
.digit__strip {
transform: translateY(0); // giá trị thật do keyframes bên dưới quyết định
animation: roll-to-8 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
span { height: 44px; }
}
@keyframes roll-to-8 {
0% { transform: translateY(0); }
60%, 100% { transform: translateY(-80%); } // dừng ở hàng thứ 8 (-80%)
}
09Bộ đếm ngược thời gian
Chuyển động thế nào
@property --t đếm ngược từ 9 về 0 thay vì đếm lên một mục tiêu, và cùng giá trị --t đó vừa co ngắn thanh tiến độ bằng scaleX vừa chuyển màu chữ số sang màu cảnh báo ở giai đoạn cuối.

<main class="stage">
<div class="counter is-demo">
<span class="counter__num" aria-hidden="true"></span>
<span class="counter__sr">0</span>
<span class="counter__bar" aria-hidden="true"></span>
<span class="counter__label">Còn lại</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
@property --t {
syntax: '<integer>';
initial-value: 9;
inherits: true; // 숫자와 아래 바 둘 다 같은 값을 읽는다
}
.counter {
--t: 9;
text-align: center;
color: $color; // 남은 수가 줄어들수록 이 색이 경고색으로 바뀐다
&__num {
counter-reset: t var(--t);
display: block;
font-weight: 800; font-size: clamp(44px, 14vw, 72px); line-height: 1; font-family: inherit;
font-variant-numeric: tabular-nums;
color: inherit;
&::before { content: counter(t); }
}
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 남은 수만큼 채워진 바 — 숫자가 줄어드는 만큼 짧아져 변한 픽셀 면적을 키운다
&__bar {
display: block;
width: clamp(90px, 26vw, 130px);
height: 6px;
margin: clamp(10px, 3vw, 14px) auto 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
overflow: hidden;
&::after {
content: "";
display: block;
height: 100%;
background: currentColor; // 부모(.counter)의 color 를 그대로 따라간다
transform-origin: left; // 규칙8: 남은 시간 왼쪽 기준 바
transform: scaleX(calc(var(--t) / 9));
}
}
&__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: rgba(255, 255, 255, .6);
}
}
// 목표값이 아니라 0을 향해 거꾸로 센다. 색은 부모에서 함께 애니메이션해 숫자·바가 같이 바뀐다
.counter.is-demo {
animation: count-down $duration $easing infinite;
}
@keyframes count-down {
0% { --t: 9; color: $color; }
60% { --t: 3; color: $color; }
85% { --t: 0; color: $error; }
100% { --t: 0; color: $error; }
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo { animation: none; --t: 0; color: $error; }
}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);
}
@property --t {
syntax: "<integer>";
initial-value: 9;
inherits: true;
}
.counter {
--t: 9;
text-align: center;
color: #fff7e6;
}
.counter__num {
counter-reset: t var(--t);
display: block;
font-weight: 800;
font-size: clamp(44px, 14vw, 72px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: inherit;
}
.counter__num::before {
content: counter(t);
}
.counter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.counter__bar {
display: block;
width: clamp(90px, 26vw, 130px);
height: 6px;
margin: clamp(10px, 3vw, 14px) auto 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
overflow: hidden;
}
.counter__bar::after {
content: "";
display: block;
height: 100%;
background: currentColor;
transform-origin: left;
transform: scaleX(calc(var(--t) / 9));
}
.counter__label {
display: block;
margin-top: clamp(6px, 2vw, 10px);
font-weight: 600;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, 0.6);
}
.counter.is-demo {
animation: count-down 2s ease-out infinite;
}
@keyframes count-down {
0% {
--t: 9;
color: #fff7e6;
}
60% {
--t: 3;
color: #fff7e6;
}
85% {
--t: 0;
color: #c2361a;
}
100% {
--t: 0;
color: #c2361a;
}
}
@media (prefers-reduced-motion: reduce) {
.counter.is-demo {
animation: none;
--t: 0;
color: #c2361a;
}
}import type { CSSProperties } from "react";
import styles from "./CountdownTimer.module.scss";
export type CountdownTimerProps = {
/** 몇에서 시작해 0으로 향할지 */
from: number;
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--t-start"?: number };
export default function CountdownTimer({
from, label, duration = 2000, easing = "ease-out", color = "#fff7e6",
}: CountdownTimerProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--t-start": from,
};
return (
<div className={styles.counter} style={vars}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>0</span>
<span className={styles.bar} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</div>
);
}09 · Bộ đếm ngược thời gian — Diện tích đo được 1.755% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Hướng đếm ngược này hợp với hạn chót số lượng có hạn, sự kiện sắp kết thúc hay cảnh báo sắp hết hàng.
@keyframes count-down {
0% { --t: 9; color: $color; }
60% { --t: 3; color: $color; }
85% { --t: 0; color: $error; }
100% { --t: 0; color: $error; }
}
.bar::after {
transform-origin: left;
transform: scaleX(calc(var(--t) / 9)); // cùng --t nên thanh cũng rút ngắn theo
}
Bẫy đếm số nằm ở đâu?
Vùng hỗ trợ @property
Cả chín cách đếm đều dựa vào @property, và at-rule này chỉ chạy được từ Chrome·Edge 85, Safari 16.4 và Firefox 128 trở lên, theo bảng hỗ trợ @property trên caniuse. Trên trình duyệt cũ hơn, animation bị bỏ qua hoàn toàn và con số cuối cùng hiện thẳng ra ngay từ đầu — bố cục không vỡ, chỉ mất hẳn cảm giác đang đếm lên.
Con số vẽ bằng counter không phải văn bản
Bẫy thứ hai nằm ở chính counter(): con số nó vẽ ra không phải văn bản thật, nên không thể dùng chuột bôi đen để chọn, và trình đọc màn hình cũng bỏ qua, đúng như tài liệu nội dung sinh ra của MDN ghi rõ. Vì vậy cả chín demo trong zip đều giấu thêm một đoạn văn bản số thật ngay bên cạnh, chỉ ẩn đi bằng CSS chứ không xoá khỏi DOM — mở gói bằng mật khẩu j9xejany để thấy đúng cách giấu đó trong từng file.
Khả năng tiếp cận
Giảm chuyển động và số thật trong DOM
Cả chín cách đếm đều tôn trọng prefers-reduced-motion: animation tắt hẳn và mỗi ô hiện thẳng giá trị cuối cùng — mục 01 dừng ở 12.480, mục 03 ở 4,98, mục 05 ở 76% — không qua bước đếm nào cả, kể cả mục 07 vốn chờ cuộn tới cũng khoá sẵn ở 1.280 thay vì đứng ở 0. Vì counter() không phải văn bản thật, chín demo đều giữ thêm một span chứa số thật, chỉ ẩn khỏi mắt bằng clip-path: inset(50%) nhưng vẫn nằm trong DOM cho trình đọc màn hình. Mục 09 đổi màu chữ số sang màu cảnh báo để báo hiệu khẩn cấp, nhưng không phải ai cũng phân biệt được màu sắc, nên con số tự nó vẫn phải mang đủ nghĩa. Trang prefers-reduced-motion của MDN ghi mức hỗ trợ trình duyệt cho media query này.
Các hiệu ứng chuyển động khác cùng kiểu chín hiệu ứng trong một file nằm trong chuyên mục CSS của site, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đóng gói thành một file zip có khoá.
FAQ
CSS number counter animation có cần JavaScript không?
Gần như không. Tám trong chín cách đếm chạy trọn bằng @property và CSS animation, không có phép tính nào phải nhờ đến JavaScript. Riêng mục 07 mượn vài dòng IntersectionObserver để quyết định thời điểm bắt đầu, nhưng việc tăng số vẫn do CSS đảm nhiệm — nên xem qua bảng hỗ trợ @property trước khi dùng, vì trình duyệt cũ sẽ bỏ qua animation này.
Số do counter() vẽ ra có copy được không?
Không copy được bằng chuột và trình đọc màn hình cũng bỏ qua, vì đó là nội dung sinh ra chứ không phải node văn bản thật trong DOM. Nếu cần cho phép người dùng copy được số đó (như bảng giá), hãy hiện hẳn span văn bản thật đã giấu sẵn trong mỗi demo thay vì để nguyên trạng thái ẩn.
Odometer ở mục 08 có đếm được số hàng vạn không?
Có — chỉ cần thêm một cột digit cho mỗi chữ số mới, còn cách tính translateY của từng cột giữ nguyên như cũ. Thư mục react/ trong zip đã viết component odometer nhận một mảng chữ số dài ngắn tùy ý, nên thêm cột không cần viết lại logic cuộn.









