Swish UI
All componentsText Free

Odometer digits

Each digit is a column of 0 to 9 that rolls to its new value when the total changes.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useState } from "react";
import styles from "./demo.module.css";

const RATE = 412;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const money = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });

export default function Demo() {
  const [nights, setNights] = useState(3);
  const total = money.format(nights * RATE);
  const chars = [...total];

  return (
    <div className={styles.root}>
      <div className={styles.card}>
        <p className={styles.label}>Villa Azul, Comporta</p>
        <p className={styles.price} aria-live="polite">
          <span className={styles.sr}>{total} for {nights} nights</span>
          <span aria-hidden="true" className={styles.digits}>
            {chars.map((c, i) => {
              const place = chars.length - i;
              if (!/\d/.test(c)) return <span key={`s${place}`} className={styles.sym}>{c}</span>;
              return (
                <span key={`d${place}`} className={styles.digit} style={{ "--d": Number(c), "--i": place } as React.CSSProperties}>
                  <span className={styles.strip}>
                    {DIGITS.map((d) => <span key={d}>{d}</span>)}
                  </span>
                </span>
              );
            })}
          </span>
        </p>
        <p className={styles.note}>{money.format(RATE)} per night, taxes included</p>
        <div className={styles.stepper}>
          <button type="button" className={`${styles.step} ${styles.minus}`} aria-label="Remove a night" disabled={nights <= 1} onClick={() => setNights((n) => n - 1)} />
          <span className={styles.nights}>{nights} {nights === 1 ? "night" : "nights"}</span>
          <button type="button" className={`${styles.step} ${styles.plus}`} aria-label="Add a night" disabled={nights >= 14} onClick={() => setNights((n) => n + 1)} />
        </div>
      </div>
    </div>
  );
}

demo.module.css

.root { container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.card { display: grid; justify-items: center; gap: 8px; padding: clamp(16px, 5cqh, 32px) clamp(24px, 7cqi, 48px); border-radius: 16px; background: var(--white); border: 1px solid var(--line); text-align: center; }
.label { font-size: 13px; font-weight: 600; color: var(--muted); }
.price { font-size: clamp(36px, min(11cqi, 18cqh), 72px); font-weight: 700; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.digits { display: inline-flex; height: 1.1em; line-height: 1.1em; }
.digit { display: inline-block; height: 1.1em; overflow: hidden; animation: enter 0.45s var(--out); }
.strip { display: flex; flex-direction: column; translate: 0 calc(var(--d) * -1.1em); transition: translate 0.7s var(--out) calc(var(--i) * 40ms); }
.strip span, .sym { display: block; height: 1.1em; }
.sym { display: inline-block; }
@keyframes enter { from { opacity: 0; translate: 0 0.3em; } }
.note { font-size: 13px; color: var(--muted); }
.stepper { display: flex; align-items: center; gap: 14px; margin-top: 6px; }
.nights { min-width: 5.5em; font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.step { position: relative; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line); background: var(--white); cursor: pointer; transition: border-color 0.18s, opacity 0.18s; }
.step:hover:not(:disabled) { border-color: var(--ink); }
.step:active:not(:disabled) { scale: 0.95; }
.step:disabled { opacity: 0.4; cursor: default; }
.step::before, .plus::after { content: ""; position: absolute; left: 50%; top: 50%; width: 14px; height: 2px; border-radius: 1px; background: var(--ink); translate: -50% -50%; }
.plus::after { rotate: 90deg; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .strip, .digit { transition: none; animation: none; } }

Use it for

  • Totals that change from a user action, like nights, guests or seats
  • Live counters such as bookings today

Avoid

  • Values that change many times a second, the rolls never settle
  • Long numbers with more than six digits, the motion turns to noise

How it works

The formatted string from Intl.NumberFormat is split into characters. Each digit renders a vertical strip of ten numbers inside a 1.1em tall window with overflow hidden, and the strip is translated by minus the digit times 1.1em. A translate transition does the roll, delayed 40ms per place so the columns ripple. Digits are keyed by place from the right, so the ones column stays the ones column when the number grows a digit.

Accessibility

A visually hidden total in an aria-live polite region is announced after each change, and the strips are aria-hidden. The stepper buttons are 44px with labels and disable at their limits. Reduced motion swaps values instantly.

Found on