Number roll
Numbers that change like an odometer: every digit rolls in the direction the number moved.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/number-roll.jsonOverview
A number that jumps from one value to the next throws away the most satisfying moment on the page. Here each digit is its own column and rolls to its new value, the way the number actually moved: going up, a 9 rolls on to 0 rather than spinning back down. New places slide open from nothing and lost places fold away, so 999 becoming 1,000 grows a digit and a comma in front of you. It formats with Intl.NumberFormat, so currency, percent, decimals and compact numbers all roll.
States
- rolling
- Each changed digit rolls up or down with the number, easing out over 0.9s; unchanged digits stay perfectly still.
- growing
- A new place (and its comma) slides open from zero width, and its digit rolls up from 0.
- shrinking
- A place that is no longer needed folds to nothing on the left.
- from
- With from, it starts at that value and rolls to the real one as soon as it’s painted, e.g. stats from 0.
Usage
import { NumberRoll } from "@/components/number-roll";
<NumberRoll value={downloads} />
<NumberRoll value={price} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />Works with
Built on it
- Pricing toggleMonthly or yearly: the thumb slides with a little give, and every price on the page rolls to its new amount.
- Waitlist fieldAn email field that becomes its own answer: the button stretches across it, then settles as "You’re on the list · #1,248".
- Code inputThe six boxes, done properly: digits rise in, a paste cascades, a wrong code shakes and clears, the right one becomes a pill with a check.
- Steps formA multi-step form in one card that changes shape: steps slide in from the way you’re going, checks shake and explain, and Create turns into what comes next.
- Date range pickerA range is one liquid band: it follows your pointer between days, presets flow it to their span, and the dates morph as it moves.
- Scroll storyA how-it-works section that tells itself as you scroll: steps light up at the middle, a rail fills, and one card reshapes into each step’s picture.
- Stats bandNumbers that arrive when you do: hairlines draw themselves, then each figure rolls up from zero in turn.
- Live activityOne pill for whatever is happening now: it updates in place, tap it and it grows into a panel, done blurs into a check.
- Pricing calculatorDrag to your size and watch the price roll, the plan morph and, at the top, “Let’s talk”.
- Onboarding checklistThe first five things, ticked off in place: a ring that fills, checks that draw, and a finish worth seeing.
- Sortable listDrag to reorder and everything moves: rows slide out of the way, the dropped one settles, every rank rolls, and presets re-sort the lot.
- Changelog timelineWhat shipped, drawn as you read: a rail that fills, dots that light, filters that fold the rest away.
- Changelog traceWhat shipped, routed like a circuit: the line steps aside between releases, and a bead runs down it as you read.
- Changelog scrubberYour release history on a ruler you scrub: drag the playhead, or press play and watch the product ship.
- Newsletter footerA footer that’s still alive: a signup that becomes its own confirmation, drawn link underlines and your name in outline.
- Prompt composerThe input that starts it all: grows as you type, takes files, and turns Send into Stop while answering.
- Agent composerThe taller composer for agent surfaces: branch, folder and context on a tab above, permissions and model as pills that grow into their lists.
- Attachment chipA file on its way into a prompt: uploading, being read, ready or failed.
- Voice inputDictate a prompt: a waveform from your real mic level, a timer, then transcribing.
- Voice noteA voice message you can scrub and read along with: the waveform fills, the time rolls, and the transcript highlights each word as it’s said.
- Thinking indicatorFour quiet loaders for the gap before the first token, with a label, a rolling timer and a finish that draws itself.
- Thinking traceAn expandable record of what an agent did: steps, reasoning, search or tool calls.
- Tool callOne tool invocation from arguments to result: preparing, running, done, failed or denied.
- Clarifying questionThe agent pauses to ask. Pick with a click or a number key, or type your own; it folds into a one-line receipt.
- Approval cardThe agent asks before it acts: what it wants to do, the details, Approve or Deny.
- PlanAn agent’s checklist that ticks itself off: pending, in progress, done, skipped or failed.
- Web researchWatch the agent search: the query types in, page icons stack up as it reads, then it folds into a trail of searches and the pages each one found.
- Command outputA command the agent runs: the log streams in, folds away on success, and stays open on failure.
- Task progressFor work that takes minutes: phases, a live timer, stats, cancel, and the result when it’s done.
- Streaming answerAn answer as it arrives, with inline citations, actions, sources and follow-ups.
- Response versionsStep between regenerated answers with "2 / 3", and regenerate without losing the old ones.
- Inline rewriteDrag a dial and the paragraph rewrites itself in place: shared words glide, the rest blur out and rise in.
- Diff reviewAccept or reject what the AI changed, word by word for prose or line by line for code.
- Action receiptsWhat the agent actually did, one quiet line each, with Undo while a ring drains. Several stack into one.
- Error and limit statesCalm, specific notices for failed answers, being offline, rate limits and chats that grow too long.
- Usage meterCredits, messages or context left: quiet when there’s plenty, clear before it runs out.
- Usage limitsWhat an agent has left: the context window split by what fills it, and each plan limit with its share and a live reset countdown.
- Edit and resendFix a sent message where it sits. The bubble grows into an editor, then folds back and branches the reply.
- Earnings chartA bar chart that changes instead of redrawing: bars glide between periods, the scale and the total roll, a readout follows the pointer.
- Steps chartA week against a daily goal: days that make it fill in, a wave runs the way you step through the weeks, the totals roll.
- Revenue chartThis year as an area, last year behind it; point at a month and the header becomes the readout, switch the measure and the lines reshape.
- Area chartSeveral series stacked, overlapping or as shares: every band morphs between layouts, and the tiles are the legend, the switches and the readout.
- Combo chartEvery day as a bar and the trend as a line on one scale: point at a day and the header becomes its readout while the line’s dot pings where it lands.
- Stage barsA funnel you can read at a glance: every stage as a bar against the first, the drop-off between them, and widths that ease when the period changes.
- Activity ringsThree daily goals as concentric rings beside a tight legend, with the week underneath: pick a day and the rings sweep to it.
In the templates
See it at work in a whole page. The templates ship it in components/hairline/.
CutroomPricing calculator · Video editing studio landing page
HalftonePricing · Developer tool and API landing page
EmberlinePricing · AI SaaS landing page
InfluencePricing · Short-form video agency landing page
PrismPricing · AI creative app landing page
TempoPricing · Mobile app launch landing page
RelayPricing · Personal assistant and AI companion landing page
In a live metric
Give it a new value whenever yours changes; it works out which digits roll and which way.
"use client";
import { useEffect, useState } from "react";
import { NumberRoll } from "@/components/number-roll";
export function LiveSignups() {
const [count, setCount] = useState<number>(initialCount);
useEffect(() => {
const events = new EventSource("/api/signups/stream");
events.onmessage = (e) => setCount(Number(e.data));
return () => events.close();
}, []);
return (
<p className="text-5xl font-medium tracking-tight">
<NumberRoll value={count} /> <span className="text-base text-muted-foreground">founders on the list</span>
</p>
);
}Props
| Prop | Type | Description |
|---|---|---|
| value | number | The number to show; change it and the digits roll. |
| format / locales | Intl.NumberFormatOptions / string | Currency, percent, decimals, compact ("9.4K") and grouping, as Intl.NumberFormat does them. |
| prefix / suffix | string | Text around the number, e.g. "+" or " users". |
| from | number | Start here and roll to value once painted. |
| duration | numberdefault 900 | Roll time in ms. |
| direction | "up" | "down" | Always roll this way. Use "down" for a countdown’s seconds, so 00 → 59 rolls back like a clock instead of spinning forward. |
Accessibility and motion
- The full number is real text for screen readers, search and copy and paste; the rolling columns are hidden from assistive tech.
- Digits use tabular figures, so nothing shifts sideways while it rolls.
- No animation library: each column is a CSS transform, re-centred quietly after each roll.
- With reduced motion, digits change in place without rolling.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
Source
›Show the full source of number-roll.tsx (203 lines)
"use client";
import * as React from "react";
/* ─────────────────────────────────────────────────────────
* NUMBER ROLL: numbers that change like an odometer
*
* rolling each digit is a column that rolls to its new
* value, in the direction the number moved: going
* up, a 9 rolls on to 0 instead of spinning back
* places new places slide open from nothing; lost ones
* fold away
* format anything Intl.NumberFormat does: currency,
* percent, compact ("1.2K"), decimals
*
* The real number is always there as text for screen readers
* and copy and paste; the columns are only what you see.
* ───────────────────────────────────────────────────────── */
export interface NumberRollProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
value: number;
/** Intl.NumberFormat options, e.g. { style: "currency", currency: "USD" }. */
format?: Intl.NumberFormatOptions;
locales?: string | string[];
prefix?: string;
suffix?: string;
/** Start from this value and roll to `value` once mounted, e.g. 0 for stats. */
from?: number;
/** Roll duration in ms. */
duration?: number;
/** Always roll this way, e.g. "down" for a countdown's seconds, which wrap from 00 to 59. Defaults to the way the number moved. */
direction?: "up" | "down";
}
type Column = { key: string; char: string; digit: number | null };
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const SETS = 5; // the strip repeats 0–9 five times so a roll never runs out of digits
const MIDDLE = 20;
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** Digits are keyed by their place counted from the right, so the units column stays the units column. */
function columns(text: string): Column[] {
const chars = [...text];
const seen = new Map<string, number>();
let place = 0;
const out: Column[] = [];
for (let i = chars.length - 1; i >= 0; i--) {
const char = chars[i];
if (/\d/.test(char)) out.push({ key: `d${place++}`, char, digit: Number(char) });
else {
// Symbols are keyed by how many of the same symbol sit to their right, so a comma that shifts is the same comma.
const n = seen.get(char) ?? 0;
seen.set(char, n + 1);
out.push({ key: `s${char}${n}`, char, digit: null });
}
}
return out.reverse();
}
function Digit({ digit, trend, duration, reduced, rollIn }: { digit: number; trend: number; duration: number; reduced: boolean; rollIn: boolean }) {
const [pos, setPos] = React.useState(MIDDLE + (rollIn ? 0 : digit));
const [moving, setMoving] = React.useState(false);
const posRef = React.useRef(pos);
const stripRef = React.useRef<HTMLSpanElement>(null);
React.useLayoutEffect(() => {
const current = posRef.current;
const showing = ((current % 10) + 10) % 10;
if (showing === digit) return;
if (reduced) {
posRef.current = MIDDLE + digit;
setMoving(false);
setPos(MIDDLE + digit);
return;
}
const up = trend > 0 || (trend === 0 && digit > showing);
let target = up ? current + ((digit - showing + 10) % 10) : current - ((showing - digit + 10) % 10);
// Values arriving faster than a roll (a progress percent) never let it re-centre, so before it runs off the strip,
// jump the strip back by whole sets mid-roll (it looks identical) and roll on from there.
if (target < 10 || target > SETS * 10 - 11) {
const shift = Math.round((target - MIDDLE) / 10) * 10;
const strip = stripRef.current;
if (strip?.offsetHeight) {
let visual = current;
try {
visual = -new DOMMatrixReadOnly(getComputedStyle(strip).transform).m42 / (strip.offsetHeight / (SETS * 10));
} catch {}
const transition = strip.style.transition;
strip.style.transition = "none";
strip.style.transform = `translateY(${(-(visual - shift) * 100) / (SETS * 10)}%)`;
void strip.offsetHeight;
strip.style.transition = transition;
}
target -= shift;
}
posRef.current = target;
setMoving(true);
setPos(target);
}, [digit, trend, reduced]);
// After a roll, quietly re-centre on the middle set so the next roll always has room.
const settle = () => {
const centred = MIDDLE + (((posRef.current % 10) + 10) % 10);
posRef.current = centred;
setMoving(false);
setPos(centred);
};
return (
<span className="relative inline-block [clip-path:inset(0)]">
<span className="invisible">{digit}</span>
<span
ref={stripRef}
aria-hidden="true"
onTransitionEnd={settle}
className="absolute inset-x-0 top-0 flex flex-col"
style={{
transform: `translateY(${(-pos * 100) / (SETS * 10)}%)`,
transition: moving ? `transform ${duration}ms ${EASE}` : "none",
}}
>
{Array.from({ length: SETS * 10 }, (_, i) => (
<span key={i} className="block text-center">
{i % 10}
</span>
))}
</span>
</span>
);
}
/** A place that appeared after the first paint slides open; places that were there from the start just sit. */
function Place({ enter, children }: { enter: boolean; children: React.ReactNode }) {
const [entering] = React.useState(enter);
return (
<span className={`inline-block ${entering ? "[clip-path:inset(0)] animate-[ui-col-in_500ms_cubic-bezier(0.16,1,0.3,1)_both]" : ""}`}>
{children}
</span>
);
}
export function NumberRoll({ value, format, locales, prefix = "", suffix = "", from, duration = 900, direction, className = "", ...props }: NumberRollProps) {
const reduced = useReducedMotion();
const [shown, setShown] = React.useState(from ?? value);
const previous = React.useRef(shown);
const mounted = React.useRef(false);
// Roll from `from` to the real value once on screen (a frame later, so the start is painted).
React.useEffect(() => {
const frame = requestAnimationFrame(() => setShown(value));
return () => cancelAnimationFrame(frame);
}, [value]);
React.useEffect(() => {
mounted.current = true;
}, []);
const formatter = React.useMemo(() => new Intl.NumberFormat(locales, format), [locales, format && JSON.stringify(format)]); // eslint-disable-line react-hooks/exhaustive-deps
const text = `${prefix}${formatter.format(shown)}${suffix}`;
const cols = React.useMemo(() => columns(text), [text]);
const trend = direction === "up" ? 1 : direction === "down" ? -1 : Math.sign(shown - previous.current);
// Places that disappear fold away instead of vanishing.
const [leaving, setLeaving] = React.useState<Column[]>([]);
const lastCols = React.useRef(cols);
React.useLayoutEffect(() => {
const gone = lastCols.current.filter((c) => !cols.some((n) => n.key === c.key));
lastCols.current = cols;
previous.current = shown;
if (!gone.length || reduced) return;
setLeaving(gone);
const timer = window.setTimeout(() => setLeaving([]), 420);
return () => window.clearTimeout(timer);
}, [cols, shown, reduced]);
return (
<span className={`inline-flex tabular-nums ${className}`} {...props}>
<span className="sr-only">{`${prefix}${formatter.format(value)}${suffix}`}</span>
<span aria-hidden="true" className="inline-flex items-baseline whitespace-pre">
{leaving.map((c) => (
<span key={`out-${c.key}`} className="inline-block [clip-path:inset(0)] animate-[ui-col-out_400ms_cubic-bezier(0.16,1,0.3,1)_forwards]">
{c.char}
</span>
))}
{cols.map((c) => (
<Place key={c.key} enter={mounted.current && !reduced}>
{c.digit === null ? c.char : <Digit digit={c.digit} trend={trend} duration={duration} reduced={reduced} rollIn={mounted.current} />}
</Place>
))}
</span>
</span>
);
}
Registry item: /r/number-roll.json