Odometer Digits
Each digit is a vertical strip of 0–9 that rolls to its new value, with the digits on the right travelling further.
Create a React odometer number in CSS. Render each digit as a fixed-height window (height 1em, overflow hidden) containing a vertical strip of the numbers 0 to 9. To show digit d, set transform: translateY(calc(-1em * d)) on the strip with a 900ms cubic-bezier(0.16, 1, 0.3, 1) transition, and add a 60ms transition-delay per position so the digits settle from left to right. Use font-variant-numeric: tabular-nums so the width never jumps, and render separators like commas as static characters. Add a "New number" button that picks a random 6-digit value. Put the real value in aria-label on the container and announce changes with aria-live="polite". Under prefers-reduced-motion, change the digits without the roll. Caption: "Monthly visitors".
Source
'use client'
import { useState } from 'react'
import s from './demo.module.css'
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
export default function Odometer({ labels }: { labels: Record<string, string> }) {
const [value, setValue] = useState(128406)
const text = value.toLocaleString('en-US')
let pos = 0
return (
<div className={s.wrap}>
<span className={s.caption}>{labels.caption}</span>
<span className={s.number} aria-label={text} aria-live="polite">
{Array.from(text).map((ch, i) =>
/\d/.test(ch) ? (
<span key={i} className={s.window} aria-hidden="true">
<span className={s.strip} style={{ transform: `translateY(${-Number(ch)}em)`, transitionDelay: `${pos++ * 60}ms` }}>
{DIGITS.map((d) => (
<span key={d}>{d}</span>
))}
</span>
</span>
) : (
<span key={i} aria-hidden="true">
{ch}
</span>
),
)}
</span>
<button type="button" className={s.button} onClick={() => setValue(100000 + Math.floor(Math.random() * 900000))}>
{labels.shuffle}
</button>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 10px; }
.caption {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stage-muted);
}
.number {
display: flex;
font: 600 2.6rem / 1 var(--font-display);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--stage-ink);
}
.window { display: inline-block; height: 1em; overflow: hidden; }
.strip {
display: flex;
flex-direction: column;
transition: transform 900ms var(--ease-out);
}
.strip span { height: 1em; }
.button {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 8px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.strip { transition: none; }
}