Rejouice — loading0

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

Odometer stat — a number watched rather than read

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

html-css-js
<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

Pairs with

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.