Odometer stat
A number that counts up to its value when it scrolls into view, with digits locked to a monospace width.
Grade: ARuntimes: css · gsapStatus: live
0+
Pre-orders driven
Intent
A static statistic is read; a counting statistic is watched. The count is worth roughly one second of attention on a number that matters — and tabular figures are what stop the layout from jittering while it runs.
Implementation
The markup carries both an aria-hidden animating span and a visually hidden static one, so assistive tech gets the number once.
Dependencies: none
<p class="stat">
<span class="stat__value t-display" data-count-to="248" aria-hidden="true">248</span>
<span class="sr-only">248</span>
<span class="t-caption">Projects shipped</span>
</p>
<style>
.stat__value { font-variant-numeric: tabular-nums; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
<script type="module">
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
const easeOutExpo = (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
io.unobserve(entry.target);
const el = entry.target;
const to = Number(el.dataset.countTo);
const started = performance.now();
const step = (now) => {
const t = Math.min(1, (now - started) / 1100);
el.textContent = String(Math.round(easeOutExpo(t) * to));
if (t < 1) requestAnimationFrame(step);
};
el.textContent = "0";
requestAnimationFrame(step);
}
},
{ threshold: 0.6 },
);
for (const el of document.querySelectorAll("[data-count-to]")) io.observe(el);
}
</script>Use it when
- A row of three to four headline metrics.
- Results sections in case studies.
Avoid it when
- For precise values users need to read carefully or copy.
- On more than about four numbers at once, which turns a highlight into a slot machine.
Accessibility
- Render the final value in the DOM and only replace it while animating — a crawler, a screen reader, or a failed script must all see the real number.
- Wrap the counting element in `aria-hidden` and expose the final value in a visually hidden sibling, otherwise a live region announces every intermediate value.
- Reduced motion shows the final value immediately.
Performance
- `font-variant-numeric: tabular-nums` prevents per-frame reflow from digit width changes — without it this pattern causes visible layout jitter.
- One observer for the whole row; unobserve after firing.
Knobs
- duration
- Count length. Default: 1100ms
- start
- Value to count from. Default: 0
Composition
Conflicts with
None.
Instruction for Claude Code
Add odometer stats: render the final number in the DOM as text on an aria-hidden span with a data-count-to attribute, plus a visually hidden sibling carrying the same value for assistive tech. Apply font-variant-numeric: tabular-nums to stop layout jitter. Use one IntersectionObserver at 0.6 threshold that unobserves after firing, and count with requestAnimationFrame over 1100ms using an ease-out-expo function. Skip the script entirely under prefers-reduced-motion so the static value stands.