Odometer
Numbers that roll digit by digit, like an odometer.
Customize
<Odometer />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { gsap } from "gsap";
// Numbers that roll digit by digit, like the wheels of an odometer. Every digit is a window one
// character tall with a tall strip of 0-9 behind it; rolling is just sliding that strip up.
// The strip is 40 digits long (four full 0-9 turns) so a digit can spin around a few times before
// it lands. Separators, the prefix and the suffix stay still while the wheels turn.
const CELLS = 40;
export type OdometerProps = {
/** The number to show once the wheels stop. */
value?: number;
/** The number the wheels start from the first time they roll. */
from?: number;
/** How long one wheel takes to roll, in seconds. */
duration?: number;
/** How long each wheel waits after the one on its left, in seconds. */
stagger?: number;
/** Text before the number, e.g. "$". */
prefix?: string;
/** Text after the number, e.g. "%" or " subscribers". */
suffix?: string;
/** How many digits to show after the decimal mark. */
decimals?: number;
/** What goes between groups of thousands, e.g. "," or " ". Empty for none. */
separator?: string;
/** The decimal mark, e.g. "." or ",". */
decimalMark?: string;
/** When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. */
trigger?: "inView" | "load" | "change";
/** The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". */
ease?: string;
/** Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. */
className?: string;
/** Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". */
affixClassName?: string;
};
type Piece = { kind: "digit"; digit: number; fromRight: number } | { kind: "text"; char: string };
// "12480" → "12,480" as a list of digit wheels and fixed characters, left to right
function buildPieces(value: number, decimals: number, separator: string, decimalMark: string): Piece[] {
const places = Math.min(6, Math.max(0, Math.round(decimals)));
const [whole = "0", fraction = ""] = Math.abs(value).toFixed(places).split(".");
const grouped = separator ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, "\u0000") : whole;
const characters = [...(value < 0 ? "-" : ""), ...grouped, ...(places > 0 ? ["\u0001", ...fraction] : [])];
const digitCount = characters.filter((c) => c >= "0" && c <= "9").length;
let seen = 0;
return characters.map((c): Piece => {
if (c >= "0" && c <= "9") {
seen += 1;
return { kind: "digit", digit: Number(c), fromRight: digitCount - seen };
}
return { kind: "text", char: c === "\u0000" ? separator : c === "\u0001" ? decimalMark : c };
});
}
// The digits of `from`, left-padded with zeros (or trimmed) to fit the wheels we have
function startDigits(from: number, decimals: number, count: number): number[] {
const places = Math.min(6, Math.max(0, Math.round(decimals)));
const digits = Math.abs(from).toFixed(places).replace(".", "").split("").map(Number);
while (digits.length < count) digits.unshift(0);
return digits.slice(digits.length - count);
}
// The wheels fade out at the top and bottom edge of their window, so digits slide in softly.
// The digit at rest sits in the clear middle.
const fade = { maskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)" };
const wrap = (n: number) => ((n % 10) + 10) % 10;
const percentFor = (index: number) => -(index / CELLS) * 100;
export function Odometer({
value = 12480,
from = 0,
duration = 1.6,
stagger = 0.06,
prefix = "",
suffix = "",
decimals = 0,
separator = ",",
decimalMark = ".",
trigger = "inView",
ease = "power3.out",
className = "",
affixClassName = "",
}: OdometerProps) {
const boxRef = useRef<HTMLSpanElement>(null);
// The number is drawn as plain text on the server and until the first layout effect, so it
// is readable without JavaScript and never flashes the wrong digits
const [ready, setReady] = useState(false);
// False until the first full roll has finished. Until then every wheel spins, even a 0 that stays 0.
const firstRollPending = useRef(true);
const pieces = buildPieces(value, decimals, separator, decimalMark);
const plain = pieces.map((p) => (p.kind === "digit" ? p.digit : p.char)).join("");
const spoken = `${prefix}${plain}${suffix}`;
const digitCount = pieces.filter((p) => p.kind === "digit").length;
// New format = new wheels (a different number of digits, separator or decimals)
const layoutKey = `${digitCount}|${decimals}|${separator}|${decimalMark}`;
useLayoutEffect(() => setReady(true), []);
useLayoutEffect(() => {
const box = boxRef.current;
if (!ready || !box) return;
const strips = Array.from(box.querySelectorAll<HTMLElement>("[data-wheel]"));
if (strips.length === 0) return;
const end = pieces.filter((p) => p.kind === "digit").map((p) => p.digit);
const start = startDigits(from, decimals, end.length);
const indexOf = (strip: HTMLElement) => (-Number(gsap.getProperty(strip, "yPercent")) / 100) * CELLS;
const show = (strip: HTMLElement, index: number) => gsap.set(strip, { yPercent: percentFor(index) });
// Wheels that were just created sit on their start digit (or on the value, if it only rolls on change)
const fresh = strips.some((strip) => !strip.dataset.set);
if (fresh) {
strips.forEach((strip, k) => {
strip.dataset.set = "1";
show(strip, trigger === "change" ? end[k]! : start[k]!);
});
firstRollPending.current = trigger !== "change";
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
strips.forEach((strip, k) => show(strip, end[k]!));
firstRollPending.current = false;
return;
}
let timeline: gsap.core.Timeline | null = null;
let observer: IntersectionObserver | null = null;
const roll = () => {
const forced = firstRollPending.current;
const current = strips.map((strip) => wrap(indexOf(strip)));
const distance = end.map((digit, k) => wrap(digit - current[k]!));
// Like a real odometer, every wheel to the right of the first one that changes turns too
let first = distance.findIndex((d) => d > 0.001);
if (forced) first = 0;
if (first === -1) return;
timeline = gsap.timeline({
onComplete: () => {
firstRollPending.current = false;
},
});
strips.forEach((strip, k) => {
if (k < first) return;
const fromRight = strips.length - 1 - k;
// How many extra full turns this wheel makes: the right-most wheels spin the most
const turns = forced ? (fromRight === 0 ? 2 : 1) : distance[k]! > 0.001 ? (fromRight === 0 && k > first ? 1 : 0) : 1;
const travel = distance[k]! + turns * 10;
show(strip, current[k]!);
timeline!.to(
strip,
{
yPercent: percentFor(current[k]! + travel),
duration,
ease,
// Back onto the first 0-9 so the strip never runs out
onComplete: () => show(strip, end[k]!),
},
(k - first) * stagger,
);
});
};
if (firstRollPending.current && trigger === "inView") {
observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
observer?.disconnect();
roll();
},
{ threshold: 0.3 },
);
observer.observe(box);
} else {
roll();
}
return () => {
observer?.disconnect();
timeline?.kill();
};
// `pieces` changes every render but plain/layoutKey cover everything that matters in it
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, plain, layoutKey, from, trigger, duration, stagger, ease]);
const affix = (text: string) =>
text ? (
<span aria-hidden className={`whitespace-pre leading-none ${affixClassName}`}>
{text}
</span>
) : null;
return (
<span ref={boxRef} className={`inline-flex items-center leading-none whitespace-pre ${className}`} style={{ fontVariantNumeric: "tabular-nums", fontFeatureSettings: '"tnum"' }}>
<span className="sr-only">{spoken}</span>
{affix(prefix)}
<span key={ready ? layoutKey : "static"} aria-hidden className="inline-flex items-center leading-none">
{ready
? pieces.map((piece, i) =>
piece.kind === "text" ? (
<span key={`t${i}`} className="inline-block leading-none">
{piece.char}
</span>
) : (
// The window: one digit tall. The 0-9 strip slides behind it.
<span key={`d${piece.fromRight}`} className="-mx-[0.04em] inline-block h-[1em] overflow-hidden px-[0.04em] leading-none" style={fade}>
<span data-wheel className="block will-change-transform">
{Array.from({ length: CELLS }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n % 10}
</span>
))}
</span>
</span>
),
)
: plain}
</span>
{affix(suffix)}
</span>
);
}
The component. Installs to components/dimaac/odometer.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/odometer.jsonIt adds components/dimaac/odometer.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/odometer.
By hand
npm install gsap- Install the packages it uses: gsap.
- Copy the component file below into components/dimaac/odometer.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { Odometer } from "@/components/dimaac/odometer" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | 12480 | The number to show once the wheels stop. |
from | number | 0 | The number the wheels start from the first time they roll. |
duration | number | 1.6 | How long one wheel takes to roll, in seconds. |
stagger | number | 0.06 | How long each wheel waits after the one on its left, in seconds. |
prefix | string | "" | Text before the number, e.g. "$". |
suffix | string | "" | Text after the number, e.g. "%" or " subscribers". |
decimals | number | 0 | How many digits to show after the decimal mark. |
separator | string | "," | What goes between groups of thousands, e.g. "," or " ". Empty for none. |
decimalMark | string | "." | The decimal mark, e.g. "." or ",". |
trigger | "inView" | "load" | "change" | "inView" | When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. |
ease | string | "power3.out" | The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". |
className | string | "" | Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. |
affixClassName | string | "" | Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Odometer component (<Odometer />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/odometer
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/odometer.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/odometer.json
Then use it like this:
import { Odometer } from "@/components/dimaac/odometer";
<Odometer />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/odometer.tsx and install gsap.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- odometer.md
- This page as Markdown: install, props and the full source
- odometer.json
- The shadcn registry item
About
A counter whose digits are wheels. When the number scrolls into view, loads, or changes, every digit rolls up to its new value, the left-most wheel settling first and the right-most spinning the longest. It formats like a real number (thousands separator, decimal places, a prefix and a suffix), uses tabular figures so nothing shifts while it turns, and keeps the real number in screen-reader text. A new value rolls from what is on screen, and with reduced motion it simply shows the number. Generalised from the rolling counter in the GSAP course image loader.
- Installs to
components/dimaac/odometer.tsx- Uses
- gsap
- Tags
- gsap, counter, numbers, stats