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.

  • CSS
  • Text
  • Click
  • Added
Monthly visitors

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.