Odometer
A number that rolls each digit on its own wheel, up when the value grows and down when it shrinks, and slides new digits in as the number gets longer.
- Number
- Data
- Motion
Uses motion, clsx, tailwind-merge
1,284
"use client";
import * as React from "react";
import { Odometer } from "@/components/ui/odometer";
const MAX = 9_999_999;
const steps = [
{ label: "−100", name: "Subtract 100", apply: (n: number) => n - 100 },
{ label: "−1", name: "Subtract 1", apply: (n: number) => n - 1 },
{ label: "+1", name: "Add 1", apply: (n: number) => n + 1 },
{ label: "+100", name: "Add 100", apply: (n: number) => n + 100 },
{ label: "×10", name: "Multiply by 10", apply: (n: number) => n * 10 },
];
const button =
"h-9 min-w-12 rounded-xl px-3 font-mono text-sm text-zinc-600 tabular-nums transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.94] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600";
// Click fast: every wheel turns from wherever it is.
export function OdometerDemo() {
const [value, setValue] = React.useState(1284);
const set = (next: number) => setValue(Math.min(MAX, Math.max(0, Math.round(next))));
const random = () => {
// A random length too, so the commas come and go.
const digits = 1 + Math.floor(Math.random() * 7);
set(Math.random() * 10 ** digits);
};
return (
<div className="flex w-full max-w-xl flex-col items-center">
<p className="text-[clamp(3.5rem,14vw,6.5rem)] leading-none font-semibold tracking-[-0.045em] text-zinc-950 dark:text-zinc-50">
<Odometer value={value} />
</p>
<div className="mt-12 flex flex-wrap items-center justify-center gap-0.5 rounded-2xl border border-zinc-200 bg-white p-1 shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
{steps.map((step) => (
<button
key={step.label}
type="button"
aria-label={step.name}
onClick={() => set(step.apply(value))}
className={button}
>
{step.label}
</button>
))}
<span aria-hidden className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-800" />
<button type="button" onClick={random} className={button}>
Random
</button>
</div>
</div>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/odometer.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/odometerInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/odometer.tsx"use client"; import * as React from "react"; import { AnimatePresence, animate, motion, PresenceContext, useInView, useMotionValue, usePresence, usePresenceData, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * ODOMETER STORYBOARD * * rest plain text: every digit sits crisp in its own * window, on the baseline of the words around it * change each digit that differs rolls on its wheel of * 0–9: up when the value grows, down when it * shrinks, the long way round if it has to (19 → 21 * rolls the ones through 0). Digits that didn't * change don't move at all * ripple the rightmost changing digit goes first and each * place to its left follows 30ms later, like a carry * land a spring brings each wheel home in about 0.6s, * a hair past its digit and back. Long rolls are * damped a little more, so nothing ever bounces * grow a new leading digit, and its comma, roll in from * below as their width eases open; a digit that's * no longer needed rolls out and its width closes * swap symbols ($ , . % K −) don't roll. They ride along * with the layout and crossfade when they change * redirect a new value mid-roll turns every wheel from where * it is, at the speed it's going * reveal with rollOnView, the wheels wait on 0 until the * number scrolls into view, then roll up to it * * Each wheel's position is one continuous number (the ones * wheel goes 9 → 11 for 19 → 21) and its digits are drawn * by modulo, so wraps and retargets need no special cases. * Rolls are transforms on motion values: no React render * per frame. * ───────────────────────────────────────────────────────── */ const ODOMETER = { pitch: 1.05, // em between neighboring digits on a wheel; a digit arrives or leaves by one travel: 1.35, // em a symbol rolls to arrive or leave overshoot: 0.045, // furthest a wheel may run past its digit, in digits drift: 0.35, // em a changing symbol slides as it crossfades fade: 0.15, // s; reduced motion crossfades digits instead of rolling them reveal: 0.6, // share of the number in view before rollOnView rolls }; // The window a glyph shows through: 0.35em taller than the line above and // below, with negative margins so the line's height and width don't change. const WINDOW = "pointer-events-none relative block shrink-0 -mx-[0.25em] -my-[0.35em] px-[0.25em] py-[0.35em]"; // Digits are opaque within 0.4em of the line's middle, just room for the // glyph and its overshoot, and gone by 0.62em: a digit fades the moment it // leaves its place, like passing the edge of a window. A resting neighbor, a // pitch away, sits wholly in the clear. const DIGIT_MASK = "[mask-image:linear-gradient(to_bottom,transparent_calc(50%-0.62em),#000_calc(50%-0.4em),#000_calc(50%+0.4em),transparent_calc(50%+0.62em))]"; // Symbols keep a little more room, for $ and the tails of commas. const SYMBOL_MASK = "[mask-image:linear-gradient(to_bottom,transparent_calc(50%-0.72em),#000_calc(50%-0.5em),#000_calc(50%+0.58em),transparent_calc(50%+0.8em))]"; // One glyph, laid out exactly like the sizer inside the window. const CELL = "absolute inset-x-0 top-0 h-full px-[0.25em] py-[0.35em]"; const ALIGN = { start: "justify-start", center: "justify-center", end: "justify-end" }; const TEXT = { start: "text-left", center: "text-center", end: "text-right" }; // A wheel: 9, 0, 1 … 9, 0, so the window always has a neighbor on both sides. const CELLS = Array.from({ length: 12 }, (_, i) => i); type Align = keyof typeof ALIGN; type Part = { /** Stable across values: digits and commas are keyed by place, so a new leading digit doesn't re-key the rest. */ key: string; text: string; /** 0–9 for a digit, null for a symbol. */ digit: number | null; /** Place value of a digit or of the digit left of a comma: 0 is ones, -1 tenths. */ place: number | null; /** The side the slot hangs from while it's narrower than its glyph. */ align: Align; }; type Change = { sig: string; value: number; parts: Part[]; pending: boolean; /** 1 when the value grew, -1 when it shrank. */ dir: number; /** Place of the rightmost digit that changed: the ripple starts there. */ origin: number; /** Jump instead of rolling. */ instant: boolean; /** The first roll up from 0 with rollOnView. */ reveal: boolean; }; type Config = { stiffness: number; damping: number; stagger: number; delay: number; reduced: boolean; }; type Grouping = "international" | "indian"; type Formatter = { nf: Intl.NumberFormat; digits: string[]; /** The locale's thousands separator. */ separator: string; /** Fewest integer digits that get a separator: 4, 5 for "min2", never when grouping is off. */ least: number; }; const formatters = new Map<string, Formatter>(); function getFormatter(locale: string, format: Intl.NumberFormatOptions | undefined): Formatter { const id = `${locale} ${JSON.stringify(format ?? {})}`; let f = formatters.get(id); if (!f) { const nf = new Intl.NumberFormat(locale, format); // The locale's own digits, so wheels in other numbering systems still roll. const plain = new Intl.NumberFormat(locale, { numberingSystem: nf.resolvedOptions().numberingSystem, useGrouping: false, }); const useGrouping = nf.resolvedOptions().useGrouping as unknown; f = { nf, digits: CELLS.slice(0, 10).map((d) => plain.format(d)), separator: [nf, new Intl.NumberFormat(locale)] .map((n) => n.formatToParts(1e6).find((p) => p.type === "group")?.value) .find(Boolean) ?? ",", least: useGrouping === false ? Infinity : useGrouping === "min2" ? 5 : 4, }; if (formatters.size > 200) formatters.clear(); formatters.set(id, f); } return f; } /** * Moves the separators to a grouping of its own: threes all the way * (1,234,567), or the Indian lakh and crore, three then twos (12,34,567). * Everything else the locale chose, symbols and separator alike, stays. */ function regroup( raw: Intl.NumberFormatPart[], grouping: Grouping | undefined, { separator, least }: Pick<Formatter, "separator" | "least">, ) { if (!grouping || least === Infinity) return raw; const integer = Array.from( raw .filter((p) => p.type === "integer") .map((p) => p.value) .join(""), ); const out: Intl.NumberFormatPart[] = []; let placed = false; for (const p of raw) { if (p.type === "group") continue; if (p.type !== "integer") { out.push(p); continue; } if (placed) continue; placed = true; const groups: string[] = []; let end = integer.length; let size = integer.length < least ? end : 3; while (end > 0) { groups.unshift(integer.slice(Math.max(0, end - size), end).join("")); end -= size; if (grouping === "indian") size = 2; } groups.forEach((value, i) => { if (i > 0) out.push({ type: "group", value: separator }); out.push({ type: "integer", value }); }); } return out; } function toParts(raw: Intl.NumberFormatPart[], digits: string[]): Part[] { let place = 0; for (const p of raw) if (p.type === "integer") place += Array.from(p.value).length; const parts: Part[] = []; const seen: Record<string, number> = {}; let fraction = 0; let inside = false; // past the first digit for (const p of raw) { if (p.type === "integer" || p.type === "fraction") { inside = true; for (const char of Array.from(p.value)) { const at = p.type === "integer" ? --place : -++fraction; const digit = digits.indexOf(char); parts.push({ key: `d${at}`, text: char, digit: digit < 0 ? null : digit, place: at, align: at < 0 ? "start" : "end", }); } } else if (p.type === "group") { parts.push({ key: `g${place}`, text: p.value, digit: null, place, align: "end" }); } else if (p.type === "decimal") { inside = true; parts.push({ key: "decimal", text: p.value, digit: null, place: null, align: "center" }); } else { // + and − share a slot, so a sign flip crossfades; so do K, M and B. const type = p.type === "plusSign" || p.type === "minusSign" ? "sign" : p.type; const id = `${inside ? "post" : "pre"}-${type}`; seen[id] = (seen[id] ?? -1) + 1; parts.push({ key: `${id}-${seen[id]}`, // A true minus, the same width as the plus it swaps with. text: p.type === "minusSign" ? p.value.replace("-", "−") : p.value, digit: null, place: null, align: inside ? "start" : "end", }); } } return parts; } /** The rightmost place whose digit differs, counting added and removed digits. */ function originOf(before: Part[], after: Part[]) { const was = new Map(before.map((p) => [p.key, p])); const is = new Map(after.map((p) => [p.key, p])); let origin = Infinity; for (const p of after) { if (p.place !== null && was.get(p.key)?.text !== p.text) origin = Math.min(origin, p.place); } for (const p of before) { if (p.place !== null && !is.has(p.key)) origin = Math.min(origin, p.place); } return Number.isFinite(origin) ? origin : 0; } const mod10 = (n: number) => ((n % 10) + 10) % 10; /** * The roll's spring. Overshoot grows with the distance rolled, so long rolls * (and fast retargets) get just enough extra damping to stay under * ODOMETER.overshoot: a hint past the digit, never a bounce. */ function rollSpring(cfg: Config, distance: number, velocity: number) { const omega = Math.sqrt(cfg.stiffness); const zeta = cfg.damping / (2 * omega); const reach = Math.abs(distance) + Math.max(0, Math.sign(distance) * velocity) / omega; let damping = cfg.damping; if (zeta < 1 && reach > ODOMETER.overshoot) { const r = Math.min(Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)), ODOMETER.overshoot / reach); const ln = Math.log(r); damping = Math.max(damping, (-2 * omega * ln) / Math.sqrt(Math.PI * Math.PI + ln * ln)); } return { type: "spring" as const, stiffness: cfg.stiffness, damping, restDelta: 0.001, restSpeed: 0.01 }; } /** Width opens and closes on the same spring, critically damped: layout never wobbles. */ function widthSpring(cfg: Config) { return { type: "spring" as const, stiffness: cfg.stiffness, damping: 2 * Math.sqrt(cfg.stiffness), restDelta: 0.1, restSpeed: 1, }; } const subscribe = () => () => {}; const offscreen: React.RefObject<Element | null> = { current: null }; export interface OdometerProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> { /** The number to show. Changes roll. */ value: number; /** Intl.NumberFormat options: currency, decimals, percent, compact, units. */ format?: Intl.NumberFormatOptions; /** Locale for formatting. Fixed by default so the server and browser agree. */ locale?: string; /** Spring stiffness of the roll. Higher is snappier. */ stiffness?: number; /** Spring damping of the roll. Lower overshoots more; long rolls get a little extra. */ damping?: number; /** Seconds between neighboring places starting to roll, from right to left. */ stagger?: number; /** Seconds to wait before rolling, after a change or on scrolling into view. */ delay?: number; /** Hold every wheel on 0 until the number first scrolls into view, then roll up to it. */ rollOnView?: boolean; /** Where the separators go: by thousands, or by lakh and crore. Follows the locale when unset. */ grouping?: Grouping; } /** * A number that rolls each digit on its own wheel. Inline: it inherits the * font, size and color, and sits on the baseline of the text around it. */ export function Odometer({ value, format, locale = "en-US", stiffness = 150, damping = 18, stagger = 0.03, delay = 0, rollOnView = false, grouping, className, ...props }: OdometerProps) { const ref = React.useRef<HTMLSpanElement>(null); // False while rendering on the server and hydrating that HTML, so both draw // the final value; true from the first render after. const hydrated = React.useSyncExternalStore(subscribe, () => true, () => false); const reduced = (useReducedMotion() ?? false) && hydrated; const seen = useInView(rollOnView ? ref : offscreen, { once: true, amount: ODOMETER.reveal }); const pending = rollOnView && hydrated && !seen && !reduced; const { nf, digits, separator, least } = getFormatter(locale, format); const raw = regroup(nf.formatToParts(value), grouping, { separator, least }); const final = toParts(raw, digits); const parts = pending ? final.map((p) => (p.digit === null ? p : { ...p, digit: 0, text: digits[0] })) : final; const shown = pending ? 0 : value; const sig = parts.map((p) => p.key + p.text).join(" "); const [change, setChange] = React.useState<Change>(() => ({ sig, value: shown, parts, pending, dir: 1, origin: 0, instant: true, reveal: false, })); if (change.sig !== sig || change.pending !== pending) { const reveal = change.pending && !pending; setChange({ sig, value: shown, parts, pending, dir: reveal || shown >= change.value ? 1 : -1, origin: originOf(change.parts, parts), // Anything that happens before the reveal happens off screen. instant: pending, reveal, }); } const cfg = React.useMemo<Config>( () => ({ stiffness, damping, stagger, delay, reduced }), [stiffness, damping, stagger, delay, reduced], ); return ( <span ref={ref} className={cn("relative inline-flex items-baseline whitespace-pre tabular-nums lining-nums", className)} {...props} > <span className="sr-only">{raw.map((p) => p.value).join("")}</span> <AnimatePresence initial={false} custom={change}> {change.parts.map((part) => ( <Slot key={part.key} part={part} change={change} cfg={cfg} digits={digits} full={rollOnView && hydrated} /> ))} </AnimatePresence> </span> ); } /** * One digit or symbol. Owns its presence: a slot that arrives rolls in as its * width opens, and one that leaves rolls out as its width closes. */ function Slot({ part, change, cfg, digits, full, }: { part: Part; change: Change; cfg: Config; /** The locale's 0–9. */ digits: string[]; full: boolean; }) { const context = React.useContext(PresenceContext); const [isPresent, safeToRemove] = usePresence(); // A leaving slot keeps its last props; the change that removed it comes from here. const removedBy = usePresenceData() as Change | undefined; const now = isPresent ? change : (removedBy ?? change); const wait = cfg.delay + (part.place === null ? 0 : Math.max(0, part.place - now.origin) * cfg.stagger); // Slots in the first render are just there; later ones arrive. const [arrives] = React.useState( () => context !== null && context.initial !== false && !change.instant && !cfg.reduced, ); const width = useMotionValue<number | "auto">(arrives ? 0 : "auto"); const shift = useMotionValue(arrives ? change.dir : 0); // in travels; +1 is one below const slotRef = React.useRef<HTMLSpanElement>(null); const sizerRef = React.useRef<HTMLSpanElement>(null); const latest = React.useRef({ wait, dir: now.dir, instant: now.instant, cfg, safeToRemove }); React.useLayoutEffect(() => { latest.current = { wait, dir: now.dir, instant: now.instant, cfg, safeToRemove }; }); // Arrive, leave, or come back while leaving. React.useLayoutEffect(() => { const slot = slotRef.current; const sizer = sizerRef.current; if (!slot || !sizer) return; const { wait, dir, instant, cfg, safeToRemove } = latest.current; const moving = Math.abs(shift.getVelocity()) > 0.01; const delay = moving ? 0 : wait; if (isPresent) { if (width.get() === "auto" && shift.get() === 0) return; if (cfg.reduced || instant) { width.set("auto"); shift.jump(0); return; } if (width.get() === "auto") width.jump(slot.getBoundingClientRect().width); animate(width, sizer.getBoundingClientRect().width, { ...widthSpring(cfg), delay, onComplete: () => width.set("auto"), }); animate(shift, 0, { ...rollSpring(cfg, -shift.get(), shift.getVelocity()), delay }); return; } if (cfg.reduced || instant) { safeToRemove?.(); return; } if (width.get() === "auto") width.jump(slot.getBoundingClientRect().width); let left = 2; const done = () => { if (--left === 0) safeToRemove?.(); }; animate(width, 0, { ...widthSpring(cfg), delay, onComplete: done }); animate(shift, -dir, { ...rollSpring(cfg, -dir - shift.get(), shift.getVelocity()), delay, onComplete: done, }); }, [isPresent, width, shift]); // A symbol that changes ("K" → "M") eases to its new width as it crossfades. // Digits all share one width, so they skip this. const natural = React.useRef<number | null>(null); React.useLayoutEffect(() => { const sizer = sizerRef.current; if (part.digit !== null || !sizer) return; const to = sizer.getBoundingClientRect().width; const from = natural.current; natural.current = to; const { cfg, instant } = latest.current; if (from === null || cfg.reduced || instant) return; if (width.get() === "auto") { if (Math.abs(from - to) < 0.5) return; width.jump(from); } animate(width, to, { ...widthSpring(cfg), onComplete: () => width.set("auto") }); }, [part.text, part.digit, width]); return ( // The wheels inside answer to this slot's presence, not to the number's. <PresenceContext.Provider value={null}> <motion.span ref={slotRef} aria-hidden className={cn("flex min-w-0 items-baseline select-none", ALIGN[part.align])} style={{ width }} > <span className={cn(WINDOW, part.digit !== null && !cfg.reduced ? DIGIT_MASK : SYMBOL_MASK)}> <span ref={sizerRef} className="invisible"> {part.text} </span> {part.digit !== null && !cfg.reduced ? ( <Wheel digit={part.digit} change={now} wait={wait} cfg={cfg} shift={shift} solo={!isPresent} digits={digits} full={full} /> ) : ( <Glyphs text={part.text} align={part.align} dir={now.dir} cfg={cfg} shift={shift} /> )} </span> </motion.span> </PresenceContext.Provider> ); } /** A strip of digits that rolls through the window. */ function Wheel({ digit, change, wait, cfg, shift, solo, digits, full, }: { digit: number; change: Change; wait: number; cfg: Config; shift: MotionValue<number>; /** Show only the digit itself, for rolling in and out without neighbors. */ solo: boolean; digits: string[]; /** Draw the whole wheel even before it turns, so a 0 can spin all the way round. */ full: boolean; }) { // A wheel that has never turned draws only its digit, so the server's HTML // is just the number. const [first] = React.useState(digit); const [turned, setTurned] = React.useState(false); if (!turned && digit !== first) setTurned(true); const pos = useMotionValue(digit); const y = useTransform( [pos, shift], ([p, s]: number[]) => `${(s - mod10(p) - 1) * ODOMETER.pitch}em`, ); const goal = React.useRef(digit); // where the wheel is heading, unwrapped const due = React.useRef(0); // when a staggered roll that hasn't started yet begins, ms const spun = React.useRef<Change | null>(null); const latest = React.useRef({ change, wait, cfg }); React.useLayoutEffect(() => { latest.current = { change, wait, cfg }; }); React.useLayoutEffect(() => { const { change, wait, cfg } = latest.current; const from = mod10(goal.current); let steps = change.dir > 0 ? mod10(digit - from) : -mod10(from - digit); // On the reveal a 0 turns once around, so every wheel comes alive. if (change.reveal && steps === 0 && spun.current !== change) steps = 10; spun.current = change; if (steps === 0) return; goal.current += steps; if (change.instant) { pos.jump(goal.current); return; } // A wheel already turning redirects at once. One still waiting for its // turn in the ripple keeps its place in line, so fast changes can't hold // it back forever. const t = performance.now(); const moving = Math.abs(pos.getVelocity()) > 0.01; const delay = t < due.current ? due.current - t : moving ? 0 : wait * 1000; due.current = t + delay; animate(pos, goal.current, { ...rollSpring(cfg, goal.current - pos.get(), pos.getVelocity()), delay: delay / 1000, }); }, [digit, change, pos]); const cells = solo || !(turned || full) ? [digit + 1] : CELLS; return ( <motion.span className="absolute inset-0" style={{ y }}> {cells.map((i) => ( <span key={i} className={cn(CELL, "text-center")} style={{ top: `${i * ODOMETER.pitch}em` }}> {digits[(i + 9) % 10]} </span> ))} </motion.span> ); } const swap = { enter: (dir: number) => ({ opacity: 0, y: `${dir * ODOMETER.drift}em` }), center: { opacity: 1, y: "0em" }, exit: (dir: number) => ({ opacity: 0, y: `${-dir * ODOMETER.drift}em` }), }; const fade = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } }; /** A symbol, or a digit under reduced motion: crossfades when it changes. */ function Glyphs({ text, align, dir, cfg, shift, }: { text: string; align: Align; dir: number; cfg: Config; shift: MotionValue<number>; }) { const y = useTransform(shift, (s) => `${s * ODOMETER.travel}em`); return ( <motion.span className="absolute inset-0" style={{ y }}> <AnimatePresence initial={false} custom={dir}> <motion.span key={text} custom={dir} variants={cfg.reduced ? fade : swap} initial="enter" animate="center" exit="exit" transition={ cfg.reduced ? { duration: ODOMETER.fade, ease: "linear" } : { y: rollSpring(cfg, 1, 0), opacity: { duration: 0.25, ease: "easeOut" } } } className={cn(CELL, TEXT[align])} > {text} </motion.span> </AnimatePresence> </motion.span> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Odometer. Each one installs whole, with the component included.
Pricing
Monthly and yearly plans. Prices roll down to the yearly rate and back up, the yearly total rolls down from a year of monthly bills, and Starter's $0 never moves.
Starter
$0/ month
Free forever
For side projects and trying things out.
- 3 projects
- Community support
- 7-day history
Pro
Popular$24/ month
billed monthly
For teams shipping every week.
- Unlimited projects
- Priority support
- 1-year history
Team
$59/ month
billed monthly
For companies that need control.
- SSO and audit logs
- Custom roles
- Unlimited history
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Odometer } from "@/components/ui/odometer";
const usd: Intl.NumberFormatOptions = { style: "currency", currency: "USD", maximumFractionDigits: 0 };
const plans = [
{
name: "Starter",
blurb: "For side projects and trying things out.",
monthly: 0,
yearly: 0,
features: ["3 projects", "Community support", "7-day history"],
cta: "Start for free",
featured: false,
},
{
name: "Pro",
blurb: "For teams shipping every week.",
monthly: 24,
yearly: 19,
features: ["Unlimited projects", "Priority support", "1-year history"],
cta: "Start a trial",
featured: true,
},
{
name: "Team",
blurb: "For companies that need control.",
monthly: 59,
yearly: 47,
features: ["SSO and audit logs", "Custom roles", "Unlimited history"],
cta: "Talk to sales",
featured: false,
},
];
const periods = ["Monthly", "Yearly"] as const;
// The prices roll down to the yearly rate and back up. Starter's $0 never
// moves: only digits that change turn.
export function OdometerPricingDemo() {
const [yearly, setYearly] = React.useState(false);
return (
<div className="@container w-full max-w-4xl">
<div className="flex justify-center">
<div className="relative">
<div
role="radiogroup"
aria-label="Billing period"
className="relative grid grid-cols-2 rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
initial={false}
animate={{ x: yearly ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
/>
{periods.map((period) => {
const active = (period === "Yearly") === yearly;
return (
<button
key={period}
type="button"
role="radio"
aria-checked={active}
onClick={() => setYearly(period === "Yearly")}
className={`relative min-w-24 rounded-full px-4 py-1.5 text-sm font-medium transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 ${
active
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{period}
</button>
);
})}
</div>
<div className="absolute top-1/2 left-full ml-3 -translate-y-1/2">
<AnimatePresence initial={false}>
{yearly ? (
<motion.span
initial={{ opacity: 0, x: -8, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -4, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 420, damping: 26 }}
className="block rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium whitespace-nowrap text-emerald-700 ring-1 ring-emerald-600/20 ring-inset dark:text-emerald-400 dark:ring-emerald-400/20"
>
Save 20%
</motion.span>
) : null}
</AnimatePresence>
</div>
</div>
</div>
<div className="mt-8 grid gap-4 @2xl:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.name}
className={
plan.featured
? "flex flex-col rounded-2xl bg-zinc-950 p-6 text-zinc-50 shadow-xl shadow-zinc-950/20 dark:bg-zinc-50 dark:text-zinc-950 dark:shadow-black/40"
: "flex flex-col rounded-2xl border border-zinc-200 bg-white p-6 text-zinc-950 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50"
}
>
<div className="flex items-center justify-between">
<p className="font-mono text-xs tracking-widest uppercase opacity-60">{plan.name}</p>
{plan.featured ? (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[11px] font-medium dark:bg-zinc-950/10">
Popular
</span>
) : null}
</div>
<p className="mt-5 flex items-baseline gap-1.5">
<Odometer
value={yearly ? plan.yearly : plan.monthly}
format={usd}
className="text-5xl leading-none font-semibold tracking-[-0.04em]"
/>
<span className="text-sm opacity-60">/ month</span>
</p>
{/* Both lines share one cell, so switching never moves the card. */}
<p className="mt-2 grid text-sm opacity-60">
{plan.monthly === 0 ? (
<span>Free forever</span>
) : (
<>
<motion.span
aria-hidden={yearly}
className="[grid-area:1/1]"
initial={false}
animate={{ opacity: yearly ? 0 : 1, y: yearly ? -6 : 0 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
billed monthly
</motion.span>
<motion.span
aria-hidden={!yearly}
className="[grid-area:1/1]"
initial={false}
animate={{ opacity: yearly ? 1 : 0, y: yearly ? 0 : 6 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
{/* Rolls down from a year of monthly bills as it appears. */}
billed <Odometer value={(yearly ? plan.yearly : plan.monthly) * 12} format={usd} /> yearly
</motion.span>
</>
)}
</p>
<p className="mt-5 text-sm opacity-75">{plan.blurb}</p>
<ul className="mt-5 space-y-2.5 border-t border-current/10 pt-5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 opacity-50"
>
<path d="M3 8.5 6.5 12 13 4.5" />
</svg>
{feature}
</li>
))}
</ul>
<button
type="button"
className={
plan.featured
? "mt-7 h-10 rounded-xl bg-white text-sm font-medium text-zinc-950 transition-[background-color,scale] duration-150 hover:bg-zinc-200 active:scale-[0.97] dark:bg-zinc-950 dark:text-zinc-50 dark:hover:bg-zinc-800"
: "mt-7 h-10 rounded-xl border border-zinc-200 text-sm font-medium transition-[background-color,scale] duration-150 hover:bg-zinc-100 active:scale-[0.97] dark:border-zinc-700 dark:hover:bg-zinc-800"
}
>
{plan.cta}
</button>
</div>
))}
</div>
</div>
);
}
Dashboard KPIs
Currency with cents, a count, a percentage and milliseconds. The range switcher makes big jumps in both directions; between them the numbers drift live while the cards are on screen, and the delta chips flip color and arrow with their sign.
Overview
LiveRevenue
+12.4%$48,210.50
vs. previous 24 hours
Active users
+3.8%12,842
vs. previous 24 hours
Conversion
+0.6%3.42%
vs. previous 24 hours
Avg. response
-3.1%184 ms
vs. previous 24 hours
"use client";
import * as React from "react";
import { motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { Odometer } from "@/components/ui/odometer";
type Range = "24h" | "7d" | "30d";
type Reading = { value: number; delta: number };
const ranges: { id: Range; label: string }[] = [
{ id: "24h", label: "24 hours" },
{ id: "7d", label: "7 days" },
{ id: "30d", label: "30 days" },
];
const metrics: {
id: string;
label: string;
format: Intl.NumberFormatOptions;
/** Rounds a drifting value to what the format shows. */
round: (n: number) => number;
/** How far one idle tick can drift, as a share of the value. */
wobble: number;
/** Lower is better, so a fall is shown in green. */
invert?: boolean;
data: Record<Range, Reading>;
}[] = [
{
id: "revenue",
label: "Revenue",
format: { style: "currency", currency: "USD", minimumFractionDigits: 2 },
round: (n) => Math.round(n * 100) / 100,
wobble: 0.003,
data: {
"24h": { value: 48210.5, delta: 0.124 },
"7d": { value: 312904.2, delta: 0.081 },
"30d": { value: 1284377.9, delta: 0.216 },
},
},
{
id: "users",
label: "Active users",
format: {},
round: Math.round,
wobble: 0.002,
data: {
"24h": { value: 12842, delta: 0.038 },
"7d": { value: 86315, delta: 0.052 },
"30d": { value: 341208, delta: 0.149 },
},
},
{
id: "conversion",
label: "Conversion",
format: { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 },
round: (n) => Math.round(n * 10000) / 10000,
wobble: 0.01,
data: {
"24h": { value: 0.0342, delta: 0.006 },
"7d": { value: 0.0318, delta: -0.024 },
"30d": { value: 0.0296, delta: -0.012 },
},
},
{
id: "response",
label: "Avg. response",
format: { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 0 },
round: Math.round,
wobble: 0.025,
invert: true,
data: {
"24h": { value: 184, delta: -0.031 },
"7d": { value: 201, delta: 0.047 },
"30d": { value: 176, delta: -0.063 },
},
},
];
const deltaFormat: Intl.NumberFormatOptions = {
style: "percent",
signDisplay: "exceptZero",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
};
const baseline = (range: Range) => metrics.map((m) => m.data[range]);
/** One idle tick: each reading wanders a little and is pulled back toward its range's baseline. */
function drift(readings: Reading[], range: Range, noise: number[][]): Reading[] {
return readings.map((reading, i) => {
const m = metrics[i];
const base = m.data[range];
const [a, b] = noise[i];
return {
value: m.round(reading.value + (base.value - reading.value) * 0.2 + base.value * m.wobble * (a * 2 - 1)),
delta: Math.round((reading.delta + (base.delta - reading.delta) * 0.2 + 0.006 * (b * 2 - 1)) * 1000) / 1000,
};
});
}
// The range switcher makes big jumps; between them the numbers drift every
// couple of seconds, only while the dashboard is on screen.
export function OdometerDashboardDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const live = useInView(ref, { amount: 0.3 });
const [range, setRange] = React.useState<Range>("24h");
const [readings, setReadings] = React.useState(() => baseline("24h"));
React.useEffect(() => {
if (!live) return;
let timer = 0;
const tick = () => {
const noise = metrics.map(() => [Math.random(), Math.random()]);
setReadings((current) => drift(current, range, noise));
timer = window.setTimeout(tick, 1600 + Math.random() * 900);
};
timer = window.setTimeout(tick, 1600 + Math.random() * 900);
return () => window.clearTimeout(timer);
}, [live, range]);
const choose = (next: Range) => {
setRange(next);
setReadings(baseline(next));
};
const index = ranges.findIndex((r) => r.id === range);
return (
<div ref={ref} className="@container w-full max-w-5xl">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">Overview</h3>
<span className="inline-flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="relative flex size-1.5">
{live ? (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
) : null}
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<div
role="radiogroup"
aria-label="Time range"
className="relative grid grid-cols-3 rounded-lg border border-zinc-200 bg-zinc-100 p-0.5 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-0.5 left-0.5 w-[calc((100%-0.25rem)/3)] rounded-md bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
initial={false}
animate={{ x: `${index * 100}%` }}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
/>
{ranges.map((r) => (
<button
key={r.id}
type="button"
role="radio"
aria-checked={r.id === range}
onClick={() => choose(r.id)}
className={cn(
"relative min-w-12 rounded-md px-3 py-1 font-mono text-xs transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400",
r.id === range
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
)}
>
{r.id}
</button>
))}
</div>
</div>
<div className="mt-5 grid gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
{metrics.map((m, i) => {
const { value, delta } = readings[i];
const good = m.invert ? delta <= 0 : delta >= 0;
return (
<div
key={m.id}
className="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-950/[0.03] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{m.label}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium transition-colors duration-300",
good
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
)}
>
<motion.svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3"
initial={false}
animate={{ rotate: delta < 0 ? 180 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 22 }}
>
<path d="M6 9.5v-7M3 5.5l3-3 3 3" />
</motion.svg>
<Odometer value={delta} format={deltaFormat} />
</span>
</div>
<p className="mt-4 text-[1.75rem] leading-none font-semibold tracking-[-0.03em] text-zinc-950 dark:text-zinc-50">
<Odometer value={value} format={m.format} />
</p>
<p className="mt-3 text-xs text-zinc-500 dark:text-zinc-500">
vs. previous {ranges[index].label}
</p>
</div>
);
})}
</div>
</div>
);
}
Indian Grouping
A rupee fundraiser that keeps ticking up. Switch between the Indian system of lakhs and crores (₹2,45,67,890) and international thousands (₹24,567,890): the commas roll to their new places while every digit stays put.
Raised for village libraries
Live₹2,45,67,890
"use client";
import * as React from "react";
import { motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { Odometer } from "@/components/ui/odometer";
type Grouping = "indian" | "international";
const inr: Intl.NumberFormatOptions = { style: "currency", currency: "INR", maximumFractionDigits: 0 };
const GOAL = 5_00_00_000; // ₹5 crore
const systems: { id: Grouping; label: string; sample: string }[] = [
{ id: "indian", label: "Indian", sample: "12,34,567" },
{ id: "international", label: "International", sample: "1,234,567" },
];
// Gifts keep rolling in while the card is on screen. Switching the system
// only moves the commas: no digit turns.
export function OdometerGroupingDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const live = useInView(ref, { amount: 0.4 });
const [grouping, setGrouping] = React.useState<Grouping>("indian");
const [raised, setRaised] = React.useState(2_45_67_890);
const [donors, setDonors] = React.useState(18_342);
React.useEffect(() => {
if (!live) return;
let timer = 0;
const tick = () => {
// Mostly small gifts, now and then a big one.
const gift = Math.random() < 0.15 ? 25_000 + Math.random() * 2_00_000 : 500 + Math.random() * 9_500;
setRaised((r) => Math.min(GOAL, r + Math.round(gift / 10) * 10));
setDonors((d) => d + 1);
timer = window.setTimeout(tick, 1400 + Math.random() * 1200);
};
timer = window.setTimeout(tick, 1200);
return () => window.clearTimeout(timer);
}, [live]);
const index = systems.findIndex((s) => s.id === grouping);
return (
<div ref={ref} className="flex w-full max-w-md flex-col items-center">
<div className="w-full rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-950/[0.03] sm:p-7 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-zinc-500 dark:text-zinc-400">Raised for village libraries</p>
<span className="inline-flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="relative flex size-1.5">
{live ? (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
) : null}
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<p className="mt-3 text-[clamp(2.25rem,9vw,3.25rem)] leading-none font-semibold tracking-[-0.04em] text-zinc-950 dark:text-zinc-50">
<Odometer value={raised} format={inr} grouping={grouping} />
</p>
<div className="mt-6 h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<motion.div
className="h-full origin-left rounded-full bg-emerald-500"
initial={false}
animate={{ scaleX: raised / GOAL }}
transition={{ type: "spring", stiffness: 120, damping: 24 }}
/>
</div>
<div className="mt-3 flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
<span>
<Odometer
value={donors}
grouping={grouping}
className="font-medium text-zinc-950 dark:text-zinc-50"
/>{" "}
donors
</span>
<span>
of{" "}
<Odometer
value={GOAL}
format={inr}
grouping={grouping}
className="font-medium text-zinc-950 dark:text-zinc-50"
/>{" "}
goal
</span>
</div>
</div>
<div
role="radiogroup"
aria-label="Comma grouping"
className="relative mt-5 grid grid-cols-2 rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
initial={false}
animate={{ x: `${index * 100}%` }}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
/>
{systems.map((system) => (
<button
key={system.id}
type="button"
role="radio"
aria-checked={system.id === grouping}
onClick={() => setGrouping(system.id)}
className={cn(
"relative flex items-baseline justify-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium whitespace-nowrap transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400",
system.id === grouping
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
)}
>
{system.label}
<span className="font-mono text-xs font-normal opacity-60">{system.sample}</span>
</button>
))}
</div>
</div>
);
}
Landing Stats
A band of headline numbers that roll up from 0 the first time they scroll into view, one after another.
By the numbers
Shipped by teams in every time zone.
- Downloads
- 2.4M
- Countries
- 180+
- Uptime, last 12 months
- 99.99%
- Average rating
- 4.9/5
"use client";
import * as React from "react";
import { Odometer } from "@/components/ui/odometer";
const stats: {
value: number;
format?: Intl.NumberFormatOptions;
suffix?: string;
label: string;
}[] = [
{
value: 2_400_000,
format: { notation: "compact", maximumFractionDigits: 1 },
label: "Downloads",
},
{ value: 180, suffix: "+", label: "Countries" },
{
value: 0.9999,
format: { style: "percent", minimumFractionDigits: 2 },
label: "Uptime, last 12 months",
},
{
value: 4.9,
format: { minimumFractionDigits: 1 },
suffix: "/5",
label: "Average rating",
},
];
// Each number waits on 0 until the band scrolls into view, then rolls up,
// a beat after the one before it. Replay remounts the band to roll again.
export function OdometerStatsDemo() {
const [run, setRun] = React.useState(0);
return (
<section className="@container w-full max-w-4xl rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-950/[0.03] sm:p-8 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">By the numbers</p>
<h3 className="mt-2 text-xl font-semibold tracking-tight text-balance text-zinc-950 dark:text-zinc-50">
Shipped by teams in every time zone.
</h3>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ rotate: `${run * 360}deg` }}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</svg>
Replay
</button>
</div>
<dl
key={run}
className="mt-8 grid grid-cols-2 gap-y-8 border-t border-zinc-200 pt-8 @2xl:grid-cols-4 dark:border-zinc-800"
>
{stats.map((stat, i) => (
<div
key={stat.label}
className="flex flex-col-reverse gap-2 border-zinc-200 @2xl:border-l @2xl:px-6 @2xl:first:border-l-0 @2xl:first:pl-0 dark:border-zinc-800"
>
<dt className="text-sm text-zinc-500 dark:text-zinc-400">{stat.label}</dt>
<dd className="text-[clamp(2.25rem,7cqw,3.25rem)] leading-none font-semibold tracking-[-0.045em] text-zinc-950 dark:text-zinc-50">
<Odometer value={stat.value} format={stat.format} rollOnView delay={i * 0.12} />
{stat.suffix ? <span className="text-zinc-400 dark:text-zinc-500">{stat.suffix}</span> : null}
</dd>
</div>
))}
</dl>
</section>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | number | None | The number to show. When it changes, each digit that differs rolls to its new value, the long way round in the direction the value moved. |
format | Intl.NumberFormatOptions | None | How to format the number: currency, decimals, percent, compact notation, units. Digits roll; symbols like $, ,, % and K move with the layout and crossfade when they change. |
locale | string | "en-US" | Locale for formatting. Fixed rather than read from the browser, so the server and the browser render the same thing. |
stiffness | number | 150 | Spring stiffness of the roll. Higher is snappier. |
damping | number | 18 | Spring damping of the roll. Lower overshoots more. Long rolls get a little extra, so they settle a hair past their digit and never bounce. |
stagger | number | 0.03 | Seconds between neighboring places starting to roll. The rightmost changing digit goes first, so a change ripples left like a carry. |
delay | number | 0 | Seconds to wait before rolling, after a change or, with rollOnView, after scrolling into view. Use it to stagger several numbers. |
rollOnView | boolean | false | Hold every wheel on 0 until the number first scrolls into view, then roll up to it. The layout is at its final width from the start, so nothing shifts. |
grouping | "international" | "indian" | None | Where the separators go. international groups by thousands (1,234,567); indian by lakh and crore (12,34,567). Unset, it follows the locale, so en-IN is already Indian. The separator itself, and everything else, still comes from the locale and format. When it changes, the commas roll to their new places and no digit turns. |
className | string | None | Extra classes for the number. It inherits font, size, weight and color, so style it like text. Other props, like aria-live, pass through to the outer span. |
valueRequiredType
numberThe number to show. When it changes, each digit that differs rolls to its new value, the long way round in the direction the value moved.
formatType
Intl.NumberFormatOptionsHow to format the number: currency, decimals, percent, compact notation, units. Digits roll; symbols like
$,,,%andKmove with the layout and crossfade when they change.localeType
stringDefault"en-US"Locale for formatting. Fixed rather than read from the browser, so the server and the browser render the same thing.
stiffnessType
numberDefault150Spring stiffness of the roll. Higher is snappier.
dampingType
numberDefault18Spring damping of the roll. Lower overshoots more. Long rolls get a little extra, so they settle a hair past their digit and never bounce.
staggerType
numberDefault0.03Seconds between neighboring places starting to roll. The rightmost changing digit goes first, so a change ripples left like a carry.
delayType
numberDefault0Seconds to wait before rolling, after a change or, with
rollOnView, after scrolling into view. Use it to stagger several numbers.rollOnViewType
booleanDefaultfalseHold every wheel on 0 until the number first scrolls into view, then roll up to it. The layout is at its final width from the start, so nothing shifts.
groupingType
"international" | "indian"Where the separators go.
internationalgroups by thousands (1,234,567);indianby lakh and crore (12,34,567). Unset, it follows the locale, soen-INis already Indian. The separator itself, and everything else, still comes from the locale andformat. When it changes, the commas roll to their new places and no digit turns.classNameType
stringExtra classes for the number. It inherits font, size, weight and color, so style it like text. Other props, like
aria-live, pass through to the outer span.