Animated number that rolls digits odometer-style when the value changes.
This is a standalone component that does not depend on the assistant-ui runtime. Use it anywhere in your application.
Installation
npx shadcn@latest add https://r.assistant-ui.com/number-roll.jsonMain Component
"use client";import { useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties,} from "react";import { cn } from "@/lib/utils";const DEFAULT_DURATION = 500;const DIGIT_CELLS = Array.from({ length: 10 }, (_, i) => i);let supportsRoll: boolean | undefined;const canAnimate = () => { if (supportsRoll === undefined) { supportsRoll = typeof CSS !== "undefined" && typeof CSS.registerProperty === "function" && CSS.supports( "transform", "translateY(clamp(-1lh, calc((mod(7.5, 10) - 5) * 1lh), 1lh))", ); if (supportsRoll) { try { CSS.registerProperty({ name: "--aui-number-roll-pos", syntax: "<number>", inherits: true, initialValue: "0", }); } catch { /* Already registered by another copy of this component. */ } } } return supportsRoll;};/* Cached because Intl.NumberFormat construction is expensive and inline format/locales props change identity on every parent render. */const formatterCache = new Map<string, Intl.NumberFormat>();const getFormatter = ( locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined,) => { const key = `${String(locales)}\u0000${JSON.stringify(format)}`; let formatter = formatterCache.get(key); if (!formatter) { formatter = new Intl.NumberFormat(locales, format); formatterCache.set(key, formatter); } return formatter;};type DigitPart = { type: "digit"; key: string; digit: number };type SymbolPart = { type: "symbol"; key: string; value: string };type Part = DigitPart | SymbolPart;type RenderedPart = Part & { exiting?: boolean; entered?: boolean };const toParts = ( value: number, formatter: Intl.NumberFormat, prefix: string | undefined, suffix: string | undefined,): Part[] => { type Atom = | { kind: "integer"; digit: number } | { kind: "group"; value: string } | { kind: "fraction"; digit: number } | { kind: "symbol"; type: string; value: string }; const atoms: Atom[] = []; if (prefix) atoms.push({ kind: "symbol", type: "prefix", value: prefix }); for (const part of formatter.formatToParts(value)) { if (part.type === "integer" || part.type === "fraction") { for (const char of part.value) { const digit = char.charCodeAt(0) - 48; if (digit >= 0 && digit <= 9) { atoms.push({ kind: part.type, digit }); } else { atoms.push({ kind: "symbol", type: part.type, value: char }); } } } else if (part.type === "group") { atoms.push({ kind: "group", value: part.value }); } else { const type = part.type === "minusSign" || part.type === "plusSign" ? "sign" : part.type; atoms.push({ kind: "symbol", type, value: part.value }); } } if (suffix) atoms.push({ kind: "symbol", type: "suffix", value: suffix }); const counts = new Map<string, number>(); const nextKey = (type: string) => { const count = counts.get(type) ?? 0; counts.set(type, count + 1); return `${type}:${count}`; }; /* Integer digits and group separators are keyed right to left so the ones digit is always int:0. When the digit count changes (999 -> 1,000), the surviving places keep their identity and only the new leading parts enter, instead of every column being re-assigned a new meaning. */ const parts: Part[] = new Array(atoms.length); for (let i = atoms.length - 1; i >= 0; i--) { const atom = atoms[i]!; if (atom.kind === "integer") { parts[i] = { type: "digit", key: nextKey("int"), digit: atom.digit }; } else if (atom.kind === "group") { parts[i] = { type: "symbol", key: nextKey("group"), value: atom.value }; } } for (let i = 0; i < atoms.length; i++) { const atom = atoms[i]!; if (atom.kind === "fraction") { parts[i] = { type: "digit", key: nextKey("fraction"), digit: atom.digit }; } else if (atom.kind === "symbol") { parts[i] = { type: "symbol", key: `${nextKey(atom.type)}:${atom.value}`, value: atom.value, }; } } return parts;};const merge = (prev: RenderedPart[], next: Part[]): RenderedPart[] => { const nextKeys = new Set(next.map((part) => part.key)); const prevKeys = new Set(prev.map((part) => part.key)); const out: RenderedPart[] = []; let i = 0; const emitExited = (until: string | undefined) => { while (i < prev.length && prev[i]!.key !== until) { const old = prev[i++]!; if (!nextKeys.has(old.key)) { out.push(old.exiting ? old : { ...old, exiting: true }); } } }; for (const part of next) { if (prevKeys.has(part.key)) { emitExited(part.key); i++; out.push(part); } else { out.push({ ...part, entered: true }); } } emitExited(undefined); return out;};const rollDelta = (from: number, to: number, dir: number) => { const up = (((to - from) % 10) + 10) % 10; if (dir > 0) return up; if (dir < 0) return up - 10; return up > 5 ? up - 10 : up;};function NumberRollDigit({ digit, dir }: { digit: number; dir: number }) { const [state, setState] = useState({ digit, roll: digit }); if (state.digit !== digit) { setState({ digit, roll: state.roll + rollDelta(state.digit, digit, dir) }); } return ( <span data-slot="number-roll-digit" className="relative inline-block overflow-clip [transition-property:--aui-number-roll-pos] duration-(--aui-number-roll-duration) ease-(--aui-number-roll-ease) motion-reduce:transition-none" style={{ "--aui-number-roll-pos": state.roll } as CSSProperties} > {/* Digit glyphs render through ::before so find-in-page and copy never see the strip. overflow-clip (not hidden) keeps the inline-block's baseline on the text instead of the box bottom edge. */} <span data-d={digit} className="invisible before:content-[attr(data-d)]" /> {DIGIT_CELLS.map((cell) => ( <span key={cell} data-d={cell} className="absolute inset-0 text-center before:content-[attr(data-d)]" style={{ transform: `translateY(clamp(-1lh, calc((mod(mod(${cell} - var(--aui-number-roll-pos), 10) + 5, 10) - 5) * 1lh), 1lh))`, }} /> ))} </span> );}function NumberRollPart({ part, dir }: { part: RenderedPart; dir: number }) { return ( <span data-slot="number-roll-part" className={cn( "inline-grid grid-cols-[1fr] transition-[grid-template-columns,opacity,translate] duration-(--aui-number-roll-fade) ease-out motion-reduce:transition-none", part.entered && "starting:translate-y-(--aui-number-roll-shift) starting:grid-cols-[0fr] starting:opacity-0", part.exiting && "pointer-events-none translate-y-[calc(var(--aui-number-roll-shift)*-1)] grid-cols-[0fr] opacity-0", )} style={ { "--aui-number-roll-shift": dir === 0 ? "0%" : dir > 0 ? "35%" : "-35%", } as CSSProperties } > <span className="min-w-0 overflow-hidden"> {part.type === "digit" ? ( <NumberRollDigit digit={part.digit} dir={dir} /> ) : ( <span data-slot="number-roll-symbol" className="whitespace-pre"> {part.value} </span> )} </span> </span> );}export type NumberRollProps = Omit< ComponentProps<"span">, "children" | "prefix"> & { value: number; format?: Intl.NumberFormatOptions; locales?: Intl.LocalesArgument; prefix?: string; suffix?: string; trend?: "auto" | "up" | "down"; duration?: number;};/** * Animated number that rolls digits odometer-style when the value changes. Formatting is driven by `Intl.NumberFormat`, so compact notation ("1.1K"), currencies, percentages, and locale-specific output animate gracefully: digits spin in place while entering and exiting characters slide and fade. Renders the plain formatted value on the server and in browsers without CSS `mod()` support; when server rendering, pass an explicit `locales` so the server and client format identically. * * ```tsx * <NumberRoll value={count} format={{ notation: "compact" }} /> * ``` */function NumberRoll({ value, format, locales, prefix, suffix, trend = "auto", duration = DEFAULT_DURATION, className, style, ...props}: NumberRollProps) { const [enhanced, setEnhanced] = useState(false); useEffect(() => { if (canAnimate()) setEnhanced(true); }, []); const formatter = getFormatter(locales, format); const parts = useMemo( () => toParts(value, formatter, prefix, suffix), [value, formatter, prefix, suffix], ); const formatted = `${prefix ?? ""}${formatter.format(value)}${suffix ?? ""}`; const [display, setDisplay] = useState<{ value: number; formatted: string; rendered: RenderedPart[]; dir: number; }>(() => ({ value, formatted, rendered: parts, dir: 0 })); if (display.formatted !== formatted) { setDisplay({ value, formatted, rendered: merge(display.rendered, parts), dir: trend === "up" ? 1 : trend === "down" ? -1 : Math.sign(value - display.value), }); } /* Each exiting part gets its own removal timer so a new exit batch does not extend the lifetime of parts already mid-exit. The body is idempotent, so extra runs from unrelated display changes are no-ops. */ const exitTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>()); const lastDuration = useRef(duration); useEffect(() => { const timers = exitTimers.current; if (lastDuration.current !== duration) { lastDuration.current = duration; for (const timer of timers.values()) clearTimeout(timer); timers.clear(); } const exiting = new Set( display.rendered.filter((part) => part.exiting).map((part) => part.key), ); for (const [key, timer] of timers) { if (!exiting.has(key)) { clearTimeout(timer); timers.delete(key); } } for (const key of exiting) { if (timers.has(key)) continue; timers.set( key, setTimeout(() => { timers.delete(key); setDisplay((current) => ({ ...current, rendered: current.rendered.filter( (part) => !(part.exiting && part.key === key), ), })); }, duration), ); } }, [display.rendered, duration]); useEffect(() => { const timers = exitTimers.current; return () => { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); }; }, []); return ( <span data-slot="number-roll" className={cn("inline-block whitespace-nowrap tabular-nums", className)} style={ { "--aui-number-roll-duration": `${duration}ms`, "--aui-number-roll-fade": "calc(var(--aui-number-roll-duration) * 0.6)", "--aui-number-roll-ease": "cubic-bezier(0.23, 1, 0.32, 1)", ...style, } as CSSProperties } {...props} > <span className="sr-only">{formatted}</span> {enhanced ? ( <span aria-hidden className="inline-block select-none"> {display.rendered.map((part) => ( <NumberRollPart key={part.key} part={part} dir={display.dir} /> ))} </span> ) : ( <span aria-hidden>{formatted}</span> )} </span> );}export { NumberRoll };This adds a /components/assistant-ui/number-roll.tsx file to your project, which you can adjust as needed. The component has no dependencies beyond React.
Usage
import { NumberRoll } from "@/components/assistant-ui/number-roll";
export function TokenCounter({ count }: { count: number }) {
return <NumberRoll value={count} format={{ notation: "compact" }} />;
}When value changes, each digit rolls in place to its new value. Formatting is handled by Intl.NumberFormat, so structural changes animate too: when 700 becomes 1.1K with compact notation, the surviving ones digit rolls from 0 to 1 while the leading 70 slides out and the decimal point, fraction digit, and K suffix slide in.
Examples
Compact Notation
Pass any Intl.NumberFormatOptions via the format prop. Crossing a compact-notation threshold animates the formatting change instead of remounting the whole string.
Formats and Locales
Currencies, percentages, suffixes, and non-English locales all work through the same Intl.NumberFormat pipeline. Compact thresholds and suffixes are locale-dependent (1.2δΈ in zh-CN), so never hardcode them.
Live Counter
Rapid successive updates retarget the in-flight roll smoothly, which makes the component suitable for streaming token counts and other live metrics.
How It Works
The value is formatted with Intl.NumberFormat.formatToParts and split into keyed parts. Integer digits are keyed by place value counted from the right, so when 999 becomes 1,000 the existing columns keep their identity and roll in place while only the new leading digit and separator enter. Symbols (decimal point, group separators, currency signs, compact suffixes) cross-fade and collapse via animated grid-template-columns.
Each digit renders a strip of 0 to 9 and animates a registered CSS custom property; CSS mod() math wraps the strip into an endless ribbon, so rolling from 9 to 0 continues in the trend direction instead of spinning backwards. There is no JavaScript animation loop and no animation library dependency.
In browsers without CSS mod() support, and during server rendering, the component renders the plain formatted string. With prefers-reduced-motion, values swap instantly without rolling. When server rendering, pass an explicit locales: the server's default locale can differ from the visitor's browser locale, and a mismatched formatted string causes a React hydration error. Locales whose default numbering system is non-Latin (such as ar-EG) cross-fade their digits as symbols instead of rolling.
The formatted value is exposed to screen readers as plain text while the animated digits are aria-hidden. Value changes are not announced automatically; wrap the component in an aria-live region if you need announcements.
API Reference
NumberRoll
NumberRollPropsvalue: numberThe number to display.
format?: Intl.NumberFormatOptionsNumber formatting options, e.g. `{ notation: "compact" }` or `{ style: "currency", currency: "USD" }`.
locales?: Intl.LocalesArgumentLocale(s) passed to `Intl.NumberFormat`. Pass an explicit value in server-rendered apps so the server and client format identically.
prefix?: stringStatic text rendered before the number.
suffix?: stringStatic text rendered after the number.
trend: "auto" | "up" | "down"= "auto"Roll direction. `auto` follows the sign of the value change; `up` and `down` force a direction, wrapping digits through 9/0 as needed.
duration: number= 500Digit roll duration in milliseconds. Enter/exit fades run at 60% of this value.
className?: stringAdditional CSS classes.
Styling
The root applies tabular-nums so digits keep a constant width while rolling; the font you use must provide tabular figures for the layout to stay stable. Size and color are inherited from the surrounding text, so style it like any span:
<NumberRoll value={value} className="text-4xl font-semibold" />Change the animation speed via the duration prop; it drives both the CSS timing and the cleanup that unmounts exited characters, so overriding the duration variable in CSS alone would remove them mid-fade. The fade and easing variables are safe to override via the style prop (the defaults are set as inline styles, so plain className overrides do not apply):
| Variable | Default | Description |
|---|---|---|
--aui-number-roll-duration | 500ms (from the duration prop) | Digit roll duration. |
--aui-number-roll-fade | 60% of the duration | Enter/exit fade and width-collapse duration. |
--aui-number-roll-ease | cubic-bezier(0.23, 1, 0.32, 1) | Digit roll easing. |
Parts are targetable via data attributes: [data-slot="number-roll"], [data-slot="number-roll-part"], [data-slot="number-roll-digit"], and [data-slot="number-roll-symbol"].
Related Components
- Context Display - Live token usage ring and bar
- Message Timing - Streaming stats badge
- Badge - Small status and metadata labels