02Odometer counter — splitting the counter value into digits so number markers roll
Each number marker splits per digit and rolls at its own pace, so a rising value settles from the low digit upward.
Best forA ranking list, an ordered scoreboard list
ODO_DIGITStranslateY$subject-blue-light

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ol class="rank-list" id="list">
<li data-value="8">Code review challenge</li>
<li data-value="24">Weekly release notes</li>
<li data-value="61">New components</li>
</ol>
<button class="bump" id="bump" type="button">Bump the top rank up</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 자릿수 분해(value / 10^n % 10)와 자리별 전환 속도 차등에 있다 — 여기는 창·트랙만 맡는다.
// 네이티브 ::marker 는 세로로 구를(translateY) 자릿수 스트립을 담을 수 없어(자식을 못 가진다) 실제 DOM 요소로
// 마커 자리를 대신한다 — list-style:none 으로 원 마커를 지우고 aria-hidden 장식으로만 노출한다.
@include stage($stage-yellow);
.rank-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 260px;
}
.rank-list li {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
background: $subject-ink;
border-radius: $r-sm;
color: $subject-cream;
font-size: 12px;
}
.odo {
display: flex;
flex-shrink: 0;
}
.odo-col {
width: 14px;
height: 18px;
overflow: hidden;
position: relative;
font-variant-numeric: tabular-nums;
}
.odo-strip {
display: flex;
flex-direction: column;
transition: transform $dur-base $ease-out;
}
.odo-digit {
height: 18px;
line-height: 18px;
text-align: center;
color: $subject-blue-light;
font-weight: 700;
font-size: 13px;
}
.bump {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.rank-list { width: 210px; }
.rank-list li { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.odo-strip { transition: none; }
.bump { transition: none; }
}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);
}
.rank-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
}
.rank-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #17141a;
border-radius: 8px;
color: #fff7e6;
font-size: 12px;
}
.odo {
display: flex;
flex-shrink: 0;
}
.odo-col {
width: 14px;
height: 18px;
overflow: hidden;
position: relative;
font-variant-numeric: tabular-nums;
}
.odo-strip {
display: flex;
flex-direction: column;
transition: transform 300ms ease-out;
}
.odo-digit {
height: 18px;
line-height: 18px;
text-align: center;
color: #5b8cf8;
font-weight: 700;
font-size: 13px;
}
.bump {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.bump:hover {
transform: translateY(-1px);
}
.bump:active {
transform: scale(0.96);
}
.bump:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.rank-list {
width: 210px;
}
.rank-list li {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.odo-strip {
transition: none;
}
.bump {
transition: none;
}
}(function () {
var ODO_DIGITS = 2; // 자릿수 — 변형 02 목표: 2 → 3
var DIGIT_H = 18; // 숫자 스트립 한 칸 높이(px)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('bump');
var isDemo = true;
function buildOdometer(li) {
var odo = document.createElement('span');
odo.className = 'odo';
odo.setAttribute('aria-hidden', 'true');
for (var d = 0; d < ODO_DIGITS; d++) {
var col = document.createElement('span');
col.className = 'odo-col';
var strip = document.createElement('span');
strip.className = 'odo-strip';
for (var n = 0; n < 10; n++) {
var digit = document.createElement('span');
digit.className = 'odo-digit';
digit.textContent = String(n);
strip.appendChild(digit);
}
col.appendChild(strip);
odo.appendChild(col);
}
li.insertBefore(odo, li.firstChild);
return odo;
}
var odos = items.map(buildOdometer);
// 계산 겹: 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해한다. 각 자릿수 칸은 서로 다른 전환 속도를 가져
// 낮은 자리부터 먼저 자리를 잡고(빠름) 높은 자리가 뒤따른다(느림) — 단순 opacity 페이드가 아니라 자릿수별 롤링.
function setDigits(odo, value) {
var strips = odo.querySelectorAll('.odo-strip');
for (var d = 0; d < ODO_DIGITS; d++) {
var place = Math.pow(10, ODO_DIGITS - 1 - d);
var digit = Math.floor(value / place) % 10;
strips[d].style.transform = 'translateY(' + (-digit * DIGIT_H) + 'px)';
}
}
function speedClass(odo) {
var strips = odo.querySelectorAll('.odo-strip');
strips.forEach(function (s, d) {
// d=0(가장 왼쪽=가장 높은 자리)이 가장 느리고, 오른쪽(낮은 자리)일수록 빠르다
var ms = 260 + d * 220;
s.style.transitionDuration = ms + 'ms';
});
}
odos.forEach(speedClass);
var values = items.map(function (li) { return parseInt(li.getAttribute('data-value'), 10); });
function render() {
items.forEach(function (li, i) { setDigits(odos[i], values[i] % Math.pow(10, ODO_DIGITS)); });
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function bump() {
values[0] = (values[0] + 7) % Math.pow(10, ODO_DIGITS);
render();
}
button.addEventListener('click', function () { human(); bump(); });
var last = 0;
var STEP_MS = 620;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
values = [42, 97, 5]; // 계산은 유지, 시간 축소 대신 굴러간 결과 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
bump();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
render();
})();// OdometerCounterMarker — vanilla 판(run/373/demo/02-list-style-counter-flip-odometer/index.html) 의 React 포트.
// 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해하고, 각 자릿수 칸이 서로 다른 전환 속도로 굴러간다.
// 네이티브 ::marker 는 자식을 가질 수 없어 세로 스트립을 담지 못하므로 실제 DOM 요소로 마커 자리를 대신한다.
import { useEffect, useRef } from "react";
import styles from "./OdometerCounterMarker.module.scss";
type Box<T> = { current: T };
const ODO_DIGITS = 2; // 자릿수 — 변형 02 목표: 2 → 3
const DIGIT_H = 18; // 숫자 스트립 한 칸 높이(px)
const STEP_MS = 620; // 자동 루프에서 1위 값을 올리는 간격
const ROWS = [
{ label: "코드 리뷰 챌린지", value: 8 },
{ label: "주간 배포 노트", value: 24 },
{ label: "신규 컴포넌트", value: 61 },
];
export default function OdometerCounterMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
// 계산 겹: 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해한다. 각 자릿수 칸은 서로 다른 전환 속도를 가져
// 낮은 자리부터 먼저 자리를 잡고(빠름) 높은 자리가 뒤따른다(느림) — 단순 opacity 페이드가 아니라 자릿수별 롤링.
function setDigits(odo: Element, value: number) {
const strips = odo.querySelectorAll("." + (styles.odoStrip || "odo-strip"));
for (let d = 0; d < ODO_DIGITS; d++) {
const place = Math.pow(10, ODO_DIGITS - 1 - d);
const digit = Math.floor(value / place) % 10;
(strips[d] as HTMLElement).style.transform = "translateY(" + (-digit * DIGIT_H) + "px)";
}
}
function speedClass() {
items.forEach(function (li: HTMLElement) {
const odo = li.querySelector("." + (styles.odo || "odo"));
if (!odo) return;
const strips = odo.querySelectorAll("." + (styles.odoStrip || "odo-strip"));
Array.prototype.forEach.call(strips, function (s: HTMLElement, d: number) {
// d=0(가장 왼쪽=가장 높은 자리)이 가장 느리고, 오른쪽(낮은 자리)일수록 빠르다
const ms = 260 + d * 220;
s.style.transitionDuration = ms + "ms";
});
});
}
let values = ROWS.map(function (r) { return r.value; });
function render() {
items.forEach(function (li: HTMLElement, i: number) {
const odo = li.querySelector("." + (styles.odo || "odo"));
if (odo) setDigits(odo, values[i] % Math.pow(10, ODO_DIGITS));
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function bump() {
values[0] = (values[0] + 7) % Math.pow(10, ODO_DIGITS);
render();
}
function onClick() { human(); bump(); }
root!.addEventListener("click", onClick);
speedClass();
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
values = [42, 97, 5]; // 계산은 유지, 시간 축소 대신 굴러간 결과 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
bump();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
render();
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ol className={styles.rankList} ref={listRef}>
{ROWS.map(function (r) {
return (
<li key={r.label}>
<span className={styles.odo} aria-hidden="true">
{Array.from({ length: ODO_DIGITS }).map(function (_, d) {
return (
<span className={styles.odoCol} key={d}>
<span className={styles.odoStrip}>
{Array.from({ length: 10 }).map(function (_, n) {
return <span className={styles.odoDigit} key={n}>{String(n)}</span>;
})}
</span>
</span>
);
})}
</span>
{r.label}
</li>
);
})}
</ol>
<button className={styles.bump} type="button">1위 순위 올리기</button>
</div>
</main>
);
}02 Odometer counter — splitting the counter value into digits so number markers roll — Measured area 0.189% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · CSS List Style: 9 Marker Design Patterns
The post this demo lives in
CSS List Style: 9 Marker Design PatternsNine list markers: one turns each bullet like a clock as a dial angle stacks per item, one rolls a counter into odometer See all demos in this post
If you like this effect - similar demos
Lists & tables05 A Total Line Where Digits Roll Vertically as Items Are Added and the Tax Row Follows One Beat LateCSS Receipt: 9 Thermal Print and Tear-Off Scenes
Lists & tables08 Summary row stuck to the floor that recounts9 Data Table UI Patterns — Drag, Resize, Select
Lists & tables01 Inbox-zero floating card9 Empty State UI Design Cards Drawn in Pure CSS