A number whose digits roll like an odometer to the new value, with locale and Intl formatting for currency, percent and compact notation, and widths that animate as digits come and go.
Starter
$12per seat / month
Team
$49per seat / month
Scale
$199per seat / month
"use client"
import * as React from "react"
import { AnimatedNumberFlow } from "@/components/ballmac/animated-number-flow"
const plans = [
{ id: "starter", name: "Starter", monthly: 12, yearly: 9 },
{ id: "team", name: "Team", monthly: 49, yearly: 39 },
{ id: "scale", name: "Scale", monthly: 199, yearly: 159 },
]
export default function AnimatedNumberFlowDemo() {
const [yearly, setYearly] = React.useState(false)
return (
<div className="grid w-full max-w-md gap-4">
<div role="group" aria-label="Billing period" className="mx-auto inline-flex gap-0.5 rounded-lg bg-muted p-0.5">
{[false, true].map((y) => (
<button
key={String(y)}
type="button"
aria-pressed={yearly === y}
onClick={() => setYearly(y)}
className="h-8 rounded-md px-4 text-[13px] font-medium text-muted-foreground outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs"
>
{y ? "Yearly" : "Monthly"}
</button>
))}
</div>
<div className="grid grid-cols-3 gap-2">
{plans.map((p) => (
<div key={p.id} className="rounded-xl border bg-card p-3 text-center">
<p className="text-xs text-muted-foreground">{p.name}</p>
<AnimatedNumberFlow className="text-2xl font-semibold" value={yearly ? p.yearly : p.monthly} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />
<p className="text-[11px] text-muted-foreground">per seat / month</p>
</div>
))}
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/animated-number-flowInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/animated-number-flow.tsx// Ballmac UI: Animated Number Flow. https://ui.ballmac.com/components/animated-number-flow "use client" import * as React from "react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useLocale } from "@/lib/ballmac/i18n" type AnimatedNumberFlowProps = Omit<React.ComponentProps<"span">, "children"> & { /** The number to show. Change it and the digits roll to the new value. */ value: number /** BCP 47 locale for grouping and decimals. Fixed by default so the server and browser agree. */ locale?: string /** Intl.NumberFormat options: currency, percent, compact, fraction digits and so on. */ format?: Intl.NumberFormatOptions /** Text before the number, such as a unit label. */ prefix?: string /** Text after the number. */ suffix?: string /** Read each change aloud politely. Off by default because values that change often are noisy. */ announce?: boolean /** Fade the top and bottom edge of each rolling digit. */ fade?: boolean } const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] function Digit({ digit, reduce, fade }: { digit: number; reduce: boolean | null; fade: boolean }) { return ( <motion.span layout="position" initial={reduce ? false : { opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={reduce ? undefined : { opacity: 0, scale: 0.6 }} transition={{ type: "spring", stiffness: 300, damping: 30 }} className={cn("relative inline-grid overflow-hidden leading-none", fade && "[mask-image:linear-gradient(to_bottom,transparent,black_22%,black_78%,transparent)]")} style={{ height: "1em", lineHeight: 1 }} > {/* An invisible zero gives the column its width. */} <span className="invisible col-start-1 row-start-1 leading-none">0</span> <motion.span className="col-start-1 row-start-1 flex flex-col" style={{ height: "10em", lineHeight: 1 }} initial={false} animate={{ y: `${-digit * 10}%` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 20, mass: 0.9 }} > {DIGITS.map((n) => ( <span key={n} className="flex items-center justify-center" style={{ height: "1em", lineHeight: 1 }}> {n} </span> ))} </motion.span> </motion.span> ) } function AnimatedNumberFlow({ value, locale, format, prefix, suffix, announce = false, fade = true, className, ...props }: AnimatedNumberFlowProps) { const defaultLocale = useLocale() locale ??= defaultLocale const reduce = useReducedMotion() const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]) const parts = formatter.formatToParts(Number.isFinite(value) ? value : 0) const text = `${prefix ?? ""}${formatter.format(Number.isFinite(value) ? value : 0)}${suffix ?? ""}` // Digits are keyed by place value counted from the decimal point, so a ones column stays a ones column // when a thousands column appears or disappears. const intDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0) let intSeen = 0 let fracSeen = 0 const nodes: React.ReactNode[] = [] parts.forEach((part, index) => { if (part.type === "integer") { for (const ch of part.value) nodes.push(<Digit key={`i${intDigits - 1 - intSeen++}`} digit={Number(ch)} reduce={reduce} fade={fade} />) } else if (part.type === "fraction") { for (const ch of part.value) nodes.push(<Digit key={`f${fracSeen++}`} digit={Number(ch)} reduce={reduce} fade={fade} />) } else { nodes.push( <motion.span key={`${part.type}${index}`} layout="position" className="inline-block whitespace-pre"> {part.value} </motion.span> ) } }) return ( <span data-slot="animated-number-flow" className={cn("inline-flex items-center align-baseline tabular-nums", className)} {...props} > <span className="sr-only" aria-live={announce ? "polite" : undefined}> {text} </span> <span aria-hidden="true" className="inline-flex items-center"> {prefix && <span className="me-[0.25em] whitespace-pre">{prefix}</span>} <AnimatePresence initial={false} mode="popLayout"> {nodes} </AnimatePresence> {suffix && <span className="ms-[0.25em] whitespace-pre">{suffix}</span>} </span> </span> ) } export { AnimatedNumberFlow, type AnimatedNumberFlowProps }Update the import paths to match your project setup.
Usage
import { AnimatedNumberFlow } from "@/components/ballmac/animated-number-flow"The full example is in the Code tab above.
Examples
Currency, percent, compact
- Revenue
- $16,056
- Growth
- 12.8%
- Users
- 102.8K
- Latency
- 64.2ms
"use client"
import * as React from "react"
import { AnimatedNumberFlow } from "@/components/ballmac/animated-number-flow"
export default function AnimatedNumberFlowFormats() {
const [n, setN] = React.useState(1284.5)
return (
<div className="grid w-full max-w-md gap-4">
<dl className="grid grid-cols-2 gap-2">
{[
["Revenue", <AnimatedNumberFlow key="a" value={n * 12.5} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />],
["Growth", <AnimatedNumberFlow key="b" value={n / 10000} format={{ style: "percent", maximumFractionDigits: 1 }} />],
["Users", <AnimatedNumberFlow key="c" value={n * 80} format={{ notation: "compact", maximumFractionDigits: 1 }} />],
["Latency", <AnimatedNumberFlow key="d" value={n / 20} suffix="ms" format={{ maximumFractionDigits: 1, minimumFractionDigits: 1 }} />],
].map(([label, value]) => (
<div key={label as string} className="rounded-xl border bg-card p-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-2xl font-semibold">{value}</dd>
</div>
))}
</dl>
<div className="flex justify-center gap-2">
<button type="button" onClick={() => setN((v) => Math.max(0, v - 417.3))} className="h-8 rounded-md border px-3 text-[13px] font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50">
Lower
</button>
<button type="button" onClick={() => setN((v) => v + 982.7)} className="h-8 rounded-md border px-3 text-[13px] font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50">
Raise
</button>
</div>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
value*The number to show. Change it and the digits roll to the new value. | number | — |
localeBCP 47 locale for grouping and decimals. Fixed by default so the server and browser agree. | string | — |
formatIntl.NumberFormat options: currency, percent, compact, fraction digits and so on. | Intl.NumberFormatOptions | — |
prefixText before the number, such as a unit label. | string | — |
suffixText after the number. | string | — |
announceRead each change aloud politely. Off by default because values that change often are noisy. | boolean | false |
fadeFade the top and bottom edge of each rolling digit. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The formatted number is read as text; announce adds a polite live region |
| Reduced motion | The value changes instantly |
Use with AI
<AnimatedNumberFlow value format locale prefix suffix announce fade />. Change value and each digit rolls to its new place. Uses Intl.NumberFormat with a fixed default locale so server and browser agree. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Animated Number Flow (@ballmac/animated-number-flow) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Prices, balances and stats that change on interaction
- Live metrics
Not for
- A one-time count-up on scroll (number-ticker)
Registry JSON: https://ui.ballmac.com/r/animated-number-flow.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
KPI Row
A compact, semantic row of key metrics that adapts from one to four columns.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.