CSS 숫자 증가 애니메이션 9종 — 한 줄 복붙으로
CSS 숫자 증가 애니메이션은 0이나 시작값에서 목표 숫자까지 오르는 텍스트를 CSS 속성 하나로 세는 방식입니다. 아홉 개 중 하나는 거꾸로 세고, 또 하나는 스크롤해서 보이기 전엔 0에서 멈춰 있습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 @property 정수 카운트업
- 02 천 단위 콤마 자동 삽입
- 03 소수점 포맷 카운트업
- 04 통화 기호 붙은 금액 카운트업
- 05 퍼센트 링과 숫자 동기화
- 06 통계 카드 3개 순차 카운트
- 07 화면 진입 시 시작
- 08 오도미터 롤링 숫자
- 09 카운트다운 타이머
아홉 개는 인기순이 아니라 숫자 하나가 화면에서 겪는 순서로 놓았습니다. 그냥 세는 것(01) → 자릿수가 커져 읽기 어려워지는 것을 가르는 것(02) → 정밀함을 더하는 것(03) → 단위를 붙이는 것(04) → css 그래프 같은 다른 시각 요소와 값을 나누는 것(05) → 여러 개를 늘어놓는 것(06) → 언제 셀지 스스로 판단하는 것(07) → 완전히 다른 방식으로 세는 것(08) → 방향을 뒤집는 것(09) 순입니다. 코드 조각마다 실제로 움직이게 만드는 부분만 남겼고, 나머지 전체 파일은 zip에 들어 있습니다. 숫자 옆 카드의 사진이 반응해야 하면 CSS 이미지 호버 효과 9종이 그 자리를 맡고, 숫자 대신 말이 하나씩 도착하는 화면은 채팅 말풍선 CSS 애니메이션에 있습니다.
01@property 정수 카운트업
어떻게 움직이나
숫자가 0에서 목표값까지 오르는 동안 실제로 애니메이션되는 건 숫자 텍스트가 아니라 @property로 등록한 정수 커스텀 속성입니다.
어디에 쓰나
그 값을 counter()로 그대로 그려서 진짜 자릿수가 보이고, 회원 가입자 수나 방문자 수 같은 실적 카드에 씁니다.

<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">가입자</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 · @property 정수 카운트업 — 측정 누적면적 3.905% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@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; }
}
02천 단위 콤마 자동 삽입
어떻게 움직이나
12,483처럼 천 단위 콤마가 자동으로 붙으려면 천의 자리와 나머지를 서로 다른 @property 정수로 나눠 세어야 합니다.
어디에 쓰나
나머지는 @counter-style의 pad로 항상 세 자리를 채워야 "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">조회수</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 · 천 단위 콤마 자동 삽입 — 측정 누적면적 2.662% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@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소수점 포맷 카운트업
어떻게 움직이나
4.98 같은 평점은 정수부와 소수부를 각각 다른 @property로 동시에 세고, 소수부만 두 자리로 패딩해서 소수점 뒤에 붙이는 방식으로 만듭니다.
어디에 쓰나
평균 별점이나 온도처럼 소수 한두 자리가 의미를 갖는 값에 씁니다.

<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">평균 별점</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 · 소수점 포맷 카운트업 — 측정 누적면적 2.37% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@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통화 기호 붙은 금액 카운트업
어떻게 움직이나
통화 기호는 ::before로 고정해 두고 숫자만 ::after의 counter()로 애니메이션하면, 자릿수가 한 자리에서 네 자리로 늘어나도 기호 위치가 안 밀립니다.
어디에 쓰나
장바구니 합계나 결제 총액 요약 카드에 씁니다.

<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">결제 금액</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 · 통화 기호 붙은 금액 카운트업 — 측정 누적면적 3.635% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.num {
counter-reset: num var(--num);
animation: count-up .6s ease-out infinite;
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
05퍼센트 링 동기화
css 원형 그래프 애니메이션과 값을 어떻게 맞추나
정수 하나가 두 군데를 동시에 구동합니다. counter(p)로 숫자를, calc(var(--p) * 1%)로 conic-gradient 링의 채움 각도를 같이 바꾸니 숫자와 링이 어긋날 일이 없습니다.
어디에 쓰나
목표 달성률이나 저장 공간 사용률에 씁니다.

<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">달성률</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 · 퍼센트 링과 숫자 동기화 — 측정 누적면적 2.993% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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) "%"; }
}
06통계 카드 3개 순차 카운트
어떻게 움직이나
카드 세 장이 한꺼번에 채워지면 뭘 먼저 봐야 할지 모릅니다.
어디에 쓰나
animation-delay로 0.15초씩 늦게 시작시켜 순서대로 훑어보게 만들고, 대시보드 요약 카드나 랜딩 페이지 실적 3종에 씁니다.

<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">회원</span>
</div>
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">82</span>
<span class="card__label">매출</span>
</div>
<div class="card">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">5</span>
<span class="card__label">평점</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개 순차 카운트 — 측정 누적면적 1.303% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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;
}
07화면 진입 시 시작
어떻게 움직이나
숫자가 로드되자마자 다 세어져 있으면 스크롤해서 내려간 사람은 그 순간을 못 봅니다.
어디에 쓰나
카드가 뷰포트에 60% 이상 들어오는 순간에만 IntersectionObserver가 카운트를 시작시켜, 그 전엔 0에 멈춰 있습니다.

<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">누적 방문자</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 · 화면 진입 시 시작 — 측정 누적면적 6.182% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);
// .num 은 기본값이 animation-play-state: paused
// .is-counting .num 에서만 running 으로 바뀐다
08오도미터 롤링 숫자
어떻게 움직이나
자릿수마다 0~9를 세로로 늘어놓은 스트립을 두고, 목표 숫자만큼 translateY로 굴리면 각 자리가 기계식 계기판처럼 따로 돕니다.
어디에 쓰나
셋을 붙이면 "128"처럼 읽히고, 레트로 스타일 카운터나 키오스크 대기번호에 씁니다.

<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">적립 포인트</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 · 오도미터 롤링 숫자 — 측정 누적면적 0.96% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.digit {
height: 44px;
overflow: hidden; // 스트립에서 지금 칸 하나만 보인다
}
.strip {
transform: translateY(0); // 값은 아래 keyframes 가 직접 준다
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%); } // 8번째 칸(-80%) 에서 멈춘다
}
09카운트다운 타이머
어떻게 움직이나
@property 정수를 목표값이 아니라 0을 향해 거꾸로 세면 마감까지 남은 시간이나 재고를 표현할 수 있습니다.
어디에 쓰나
같은 값으로 숫자 아래 진행 막대의 길이도 함께 줄이고, 애니메이션 후반부에서는 색도 경고색으로 바꿔 다급함을 더합니다.

<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">마감까지</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 · 카운트다운 타이머 — 측정 누적면적 1.755% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@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: scaleX(calc(var(--t) / 9)); // 같은 --t 로 막대 길이도 함께 줄어든다
}
어디서 깨지는가 — 함정 셋
@property 가 없으면
@property로 만든 카운터 아홉 개 모두 같은 전제가 있습니다. @property 자체가 Chrome·Edge는 85부터, Safari는 16.4부터, Firefox는 128부터 지원됩니다(caniuse의 @property 지원표). 그보다 오래된 브라우저에서는 애니메이션 없이 최종 숫자만 바로 나타나므로 레이아웃이 깨지지는 않지만, 세는 느낌 자체가 사라집니다.
counter() 숫자는 텍스트가 아니다
두 번째 함정은 counter()로 그린 숫자가 진짜 텍스트가 아니라는 점입니다. 마우스로 드래그해도 선택되지 않고 스크린 리더도 읽지 않는다고 MDN의 생성된 콘텐츠 문서에 나와 있습니다. 그래서 이 zip의 아홉 개 데모는 전부 시각적으로만 숨긴 실제 숫자 텍스트를 하나씩 더 심어 뒀습니다. 그 대비책까지 반영해 둔 아홉 개 파일을 열려면 압축 비밀번호 j9xejany을 아래 버튼 옆 칸에 넣으면 됩니다.
다시 들어오면 이어서 센다
세 번째는 07처럼 뷰포트 진입에 반응하는 카운터의 재시작 문제입니다. animation-play-state: paused로 멈춰 둔 애니메이션은 클래스만 다시 붙여 running으로 돌려도 멈췄던 지점에서 이어질 뿐 0으로 되돌아가지 않습니다. 카드가 화면 밖으로 나갔다 다시 들어올 때마다 처음부터 다시 세게 하려면 리플로우를 강제로 한 번 넣어 애니메이션을 재시작해야 합니다. 숫자가 커지며 글자 자체가 화면을 채우는 연출까지 가면 키네틱 타이포그래피 랜딩페이지가 그 크기에서 무엇이 무너지는지 뜯어 놓았습니다.
접근성
동작 줄이기에서
아홉 개 전부 prefers-reduced-motion: reduce에서 애니메이션 없이 최종 숫자로 즉시 멈춥니다 — 숫자 자체가 사라지는 게 아니라 세는 움직임만 빠집니다. counter() 생성 숫자는 스크린 리더가 못 읽으므로 아홉 개 모두 시각적으로 숨긴 실제 숫자 텍스트를 화면 밖에 따로 둡니다.
색만으로 알리지 않는다
09처럼 색으로 다급함을 표현하는 항목은 색이 아니라 숫자 자체가 이미 정보를 담고 있는지 늘 확인하십시오. 미디어 쿼리를 실제로 어디에 거는지는 MDN 문서의 예제를 참고하십시오.
스크롤에 반응하는 다른 트리거들은 이 편에서 다 담지 못했습니다 — CSS 스크롤 애니메이션 9종에 더 있습니다. 이 사이트가 다루는 CSS 소재 전부는 CSS 애니메이션 카테고리 목록에서, 만든 사람 이야기는 소개 페이지에서 확인하십시오.
FAQ
숫자 애니메이션 css에 꼭 JS가 필요합니까?
아닙니다. 아홉 개 모두 세는 동작 자체는 @property와 CSS 애니메이션 규칙만으로 끝납니다. 07 하나만 언제 시작할지 판단하려고 IntersectionObserver 몇 줄을 곁들였을 뿐, 숫자를 증가시키는 계산은 CSS 밖으로 나가지 않습니다. 다만 caniuse의 @property 지원표는 미리 확인해 두는 게 좋습니다. 세는 숫자 옆에서 누른 순간을 알리는 표시가 필요하면 CSS 하트 애니메이션 좋아요·별점 9종이 그 짝입니다.
counter()로 그린 숫자를 복사할 수 없는데 괜찮습니까?
화면에 보여주는 용도라면 문제없지만, 사용자가 숫자를 직접 복사해야 하는 화면(가격표 등)이라면 이 zip에 함께 넣어 둔 시각적으로 숨긴 실제 텍스트 span을 화면에 드러내는 쪽으로 바꾸십시오.
3자리가 넘는 수(만 단위 등)에도 오도미터 방식을 쓸 수 있습니까?
네, 08의 자릿수 컬럼을 원하는 만큼 늘리면 됩니다. 컬럼을 늘려도 translateY 계산 방식은 그대로이고 컬럼 개수만 늘어나며, react/ 폴더의 컴포넌트는 digits 배열 길이를 그대로 따라가도록 만들어 뒀습니다.









