08Số cuộn kiểu đồng hồ odometer
Mỗi hàng chữ số có một dải dọc 0–9 riêng, cuộn bằng translateY đến đúng chữ số đích, như đồng hồ cơ mà từng cột quay độc lập.
Dùng choBộ đếm phong cách retro, bảng điểm game, số thứ tự chờ ở kiosk
translateY 스트립자릿수별 독립기계식 계기판

<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 · 9 CSS Number Counter Animation — dán 1 dòng
Bài viết chứa demo này
9 CSS Number Counter Animation — dán 1 dòngCSS 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. Chín Xem tất cả demo trong bài này


