Number roll
Digits roll like an odometer to a value, each one a column moved by transform.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/number-roll.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/number-roll
Three stats rolling on entering view, with a decimal and a unit.
Price toggle
Changing value rolls from the old number, only the digits that differ move.
Usage
import { NumberRoll } from "@/components/ui/number-roll"
<NumberRoll value={48210} className="text-7xl font-medium tracking-tight" />
<NumberRoll value={0.84} decimals={2} suffix="s" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | The number to land on, required. Changing it rolls from the old value. | |
| prefix | string | "" | Text before the digits, such as a currency sign. |
| suffix | string | "" | Text after the digits, such as a unit. |
| decimals | number | 0 | Digits after the decimal point. |
| locale | string | "en-US" | Locale for separators. Keep it fixed so the server and the browser agree. |
| duration | number | 1.6 | Seconds the entrance takes. A value change takes 0.6 of it. |
Source
number-roll/number-roll.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
const MOTION = "(prefers-reduced-motion: no-preference)";
/** 0 to 9 and a second 0, so the column can pass 9 and arrive back at the top without a jump. */
const CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const CELL = 100 / CELLS.length;
/** Where a position on the wheel sits on the strip, for any number of turns in either direction. */
const offset = (position: number) => `translateY(${-(((position % 10) + 10) % 10) * CELL}%)`;
type NumberRollProps = Omit<React.ComponentProps<"span">, "children" | "prefix"> & {
/** The number to land on. Changing it rolls from the old value. */
value: number;
/** Text before the digits, such as a currency sign. */
prefix?: string;
/** Text after the digits, such as a unit. */
suffix?: string;
/** Digits after the decimal point. */
decimals?: number;
/** Locale for separators. Pass one so the server and the browser agree. */
locale?: string;
/** Seconds a roll takes. */
duration?: number;
};
/**
* Digits roll like an odometer. Each digit is a column of 0 to 9 moved by transform, and the server
* renders every column on its final digit, so the value is right before any script runs. The entrance
* spins whole turns and lands where it began, which means its first frame matches that HTML exactly.
*/
function NumberRoll({
value,
prefix = "",
suffix = "",
decimals = 0,
locale = "en-US",
duration = 1.6,
className,
...props
}: NumberRollProps) {
const root = React.useRef<HTMLSpanElement>(null);
const previous = React.useRef<number | null>(null);
const text = Math.abs(value).toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
const whole = Math.round(Math.abs(value) * 10 ** decimals);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, (_, contextSafe) => {
const element = root.current;
if (!element || !contextSafe) return;
const strips = gsap.utils.toArray<HTMLElement>("[data-slot=number-roll-strip]");
const stagger = Math.min(0.05, 0.3 / Math.max(strips.length, 1));
const from = previous.current;
const roll = contextSafe((start: number | null) => {
previous.current = whole;
strips.forEach((strip, i) => {
const place = strips.length - 1 - i;
const digit = Number(strip.dataset.digit);
let travel: number;
if (start === null) {
// Right-hand columns turn more, as they do on a real odometer.
travel = 10 * Math.max(1, 3 - place);
} else {
const steps = Math.floor(whole / 10 ** place) - Math.floor(start / 10 ** place);
const distance = Math.abs(steps);
const turns = Math.min(Math.floor(distance / 10), Math.max(0, 2 - place));
travel = Math.sign(steps) * (distance % 10) + Math.sign(steps) * turns * 10;
}
if (!travel) return;
const wheel = { position: digit - travel };
strip.style.transform = offset(wheel.position);
gsap.to(wheel, {
position: digit,
duration: start === null ? duration : duration * 0.6,
delay: i * stagger,
ease: start === null ? "power3.inOut" : "power3.out",
onUpdate: () => {
strip.style.transform = offset(wheel.position);
},
});
});
}) as (start: number | null) => void;
if (from !== null) {
roll(from);
return;
}
let alive = true;
const watcher = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
watcher.disconnect();
void document.fonts.ready.then(() => alive && roll(null));
},
{ threshold: 0.1 },
);
watcher.observe(element);
return () => {
alive = false;
watcher.disconnect();
};
});
return () => media.revert();
},
{ scope: root, dependencies: [whole, duration], revertOnUpdate: true },
);
let place = text.replace(/\D/g, "").length;
return (
<span ref={root} data-slot="number-roll" className={cn("inline-flex tabular-nums", className)} {...props}>
<span className="sr-only">
{value < 0 ? "-" : ""}
{prefix}
{text}
{suffix}
</span>
<span aria-hidden="true" className="inline-flex select-none">
{value < 0 ? <span>-</span> : null}
{prefix ? <span>{prefix}</span> : null}
{[...text].map((character, i) => {
if (!/\d/.test(character)) return <span key={`mark-${i}`}>{character}</span>;
const digit = Number(character);
place -= 1;
return (
<span key={`place-${place}`} className="inline-block h-[1lh] overflow-clip">
<span
data-slot="number-roll-strip"
data-digit={digit}
className="flex flex-col"
style={{ transform: offset(digit) }}
>
{CELLS.map((cell, c) => (
<span key={c} className="block h-[1lh]">
{cell}
</span>
))}
</span>
</span>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
export { NumberRoll };
For AI agents
Hand your agent this page, the markdown at /components/number-roll/prompt.md, or the Copy AI prompt button above.
When to use
- Headline stats on a landing page
- A price that changes with a toggle
- A counter that updates every few seconds
When not to use
- Values in a table people scan: use plain tabular figures
- Numbers that change many times a second
- Long runs of stats, because one or a few rolls say enough
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- The server renders each column on its final digit, so the value is right with no script. The entrance spins whole turns and lands where it began, so it never shows a wrong number first
- The screen reader string is the formatted value, the columns are aria-hidden. It is not a live region, add one around it if a change must be announced
- A change that adds a digit adds a column at once, because width is not animated. Keep digit counts stable where you can
- Columns clip at 1lh, so leading-none works and tall line heights do too. Use tabular digits from the font, which the component turns on
The maths
- Position is p mod 10 and one step is 100 / 11 percent of the strip, because the strip has 11 cells with a second 0 to wrap onto.
- Place k moves floor(to / 10^k) - floor(from / 10^k) steps, kept mod 10 plus up to max(0, 2 - k) whole turns, so 0 to 12,480 is 20, 18, 4, 2 and 1 steps from the right.
- Stagger is min(50ms, 300ms / n), 50ms for six digits or fewer.