Animated numbers where only the changed digits spin, with any Intl format and locale.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
export function NumberFlowDemo() {
const [value, setValue] = React.useState(22)
return (
<div className="flex flex-col items-center gap-6">
<NumberFlow
value={value}
className="text-5xl font-semibold tracking-tight"
/>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setValue(value - 1)}>
−1
</Button>
<Button variant="outline" size="sm" onClick={() => setValue(value + 1)}>
+1
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setValue(value + 111)}
>
+111
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setValue(Math.floor(Math.random() * 100000))}
>
Random
</Button>
</div>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/number-flow.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add cn - 3
Copy and paste the following code into your project.
components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { NumberFlow } from "@/components/ui/number-flow"<NumberFlow
value={1234.5}
format={{ style: "currency", currency: "USD" }}
/>Only changed digits move#
Each digit is its own column. Going from 22 to 23 spins only the ones digit; the tens digit stays still. Digits that appear or disappear, like the third digit in 99 → 100, slide their width in or out.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
const pairs = [
[22, 23],
[14, 19],
[99, 100],
[19, 21],
]
export function NumberFlowChangedDigits() {
const [value, setValue] = React.useState(22)
return (
<div className="flex flex-col items-center gap-6">
<NumberFlow value={value} className="text-5xl font-semibold" />
<div className="flex flex-wrap justify-center gap-2">
{pairs.map(([from, to]) => (
<Button
key={`${from}-${to}`}
variant="outline"
size="sm"
onClick={() => setValue(value === from ? to : from)}
>
{from} ↔ {to}
</Button>
))}
</div>
</div>
)
}Trend#
trend sets which way digits spin. auto spins up when the value grows and down when it shrinks, up and down force a direction, and shortest takes the nearest way round the wheel.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow, type NumberFlowTrend } from "@/components/ui/number-flow"
const trends: NumberFlowTrend[] = ["auto", "up", "down", "shortest"]
export function NumberFlowTrendDemo() {
const [value, setValue] = React.useState(19)
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{trends.map((trend) => (
<div
key={trend}
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-xs text-muted-foreground">{trend}</span>
<NumberFlow
value={value}
trend={trend}
className="text-2xl font-semibold"
/>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setValue(value === 19 ? 21 : 19)}
>
19 ↔ 21
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setValue(value === 19 ? 11 : 19)}
>
19 ↔ 11
</Button>
</div>
</div>
)
}Formats#
format and locales take any Intl.NumberFormat options. Currency symbols, separators and signs stay still while the digits move, and non-Latin numerals spin in their own script.
- Currency
- $1,234.56$1012345678901234567890123456789,201234567890123456789012345678930123456789012345678901234567894012345678901234567890123456789.50123456789012345678901234567896012345678901234567890123456789
- Percent
- 42.1%40123456789012345678901234567892012345678901234567890123456789.1012345678901234567890123456789%
- Compact
- 1.2M1012345678901234567890123456789.2012345678901234567890123456789M
- Fixed decimals
- 3.143012345678901234567890123456789.10123456789012345678901234567894012345678901234567890123456789
- de-DE
- 9.876.543,219012345678901234567890123456789.801234567890123456789012345678970123456789012345678901234567896012345678901234567890123456789.501234567890123456789012345678940123456789012345678901234567893012345678901234567890123456789,20123456789012345678901234567891012345678901234567890123456789
- Signed
- 0%0012345678901234567890123456789%
- ar-EG
- ٤٬٨٢١٤٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٬٨٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٢٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩١٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨٩
- Prefix and suffix
- ~1,840 users~1012345678901234567890123456789,801234567890123456789012345678940123456789012345678901234567890012345678901234567890123456789 users
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
const factors = [1, 1.37, 0.62, 2.91]
export function NumberFlowFormats() {
const [step, setStep] = React.useState(0)
const factor = factors[step % factors.length]
const rows = [
{
label: "Currency",
value: (
<NumberFlow
value={1234.56 * factor}
format={{ style: "currency", currency: "USD" }}
/>
),
},
{
label: "Percent",
value: (
<NumberFlow
value={0.4213 * factor}
format={{ style: "percent", maximumFractionDigits: 1 }}
/>
),
},
{
label: "Compact",
value: (
<NumberFlow
value={1234567 * factor}
format={{ notation: "compact", maximumFractionDigits: 1 }}
/>
),
},
{
label: "Fixed decimals",
value: (
<NumberFlow
value={3.14159 * factor}
format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
/>
),
},
{
label: "de-DE",
value: <NumberFlow value={9876543.21 * factor} locales="de-DE" />,
},
{
label: "Signed",
value: (
<NumberFlow
value={(factor - 1) * 100}
format={{ signDisplay: "exceptZero", maximumFractionDigits: 0 }}
suffix="%"
/>
),
},
{
label: "ar-EG",
value: (
<NumberFlow
value={4821 * factor}
locales="ar-EG"
format={{ maximumFractionDigits: 0 }}
/>
),
},
{
label: "Prefix and suffix",
value: (
<NumberFlow
value={Math.round(1840 * factor)}
prefix="~"
suffix=" users"
/>
),
},
]
return (
<div className="flex w-full max-w-md flex-col gap-4">
<dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
{rows.map((row) => (
<div
key={row.label}
className="flex items-baseline justify-between gap-4 border-b py-1.5"
>
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-medium">{row.value}</dd>
</div>
))}
</dl>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setStep(step + 1)}
>
Change values
</Button>
</div>
)
}Timing#
Tune the motion with duration and easing, or turn it off with animated={false}. Reduced motion settings turn it off automatically.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
export function NumberFlowTiming() {
const [value, setValue] = React.useState(128)
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="flex flex-col gap-1 rounded-lg border p-3">
<span className="text-xs text-muted-foreground">200ms</span>
<NumberFlow
value={value}
duration={200}
className="text-2xl font-semibold"
/>
</div>
<div className="flex flex-col gap-1 rounded-lg border p-3">
<span className="text-xs text-muted-foreground">600ms</span>
<NumberFlow value={value} className="text-2xl font-semibold" />
</div>
<div className="flex flex-col gap-1 rounded-lg border p-3">
<span className="text-xs text-muted-foreground">1200ms</span>
<NumberFlow
value={value}
duration={1200}
easing="ease-in-out"
className="text-2xl font-semibold"
/>
</div>
<div className="flex flex-col gap-1 rounded-lg border p-3">
<span className="text-xs text-muted-foreground">Off</span>
<NumberFlow
value={value}
animated={false}
className="text-2xl font-semibold"
/>
</div>
</div>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setValue(value + 37)}
>
+37
</Button>
</div>
)
}Interruptions and events#
A new value mid-spin continues from where each digit is, so rapid updates never jump. onAnimationsStart and onAnimationsFinish fire once per batch of digits.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
export function NumberFlowEvents() {
const [value, setValue] = React.useState(1200)
const [status, setStatus] = React.useState("Idle")
const timers = React.useRef<number[]>([])
React.useEffect(() => {
const pending = timers.current
return () => pending.forEach((id) => window.clearTimeout(id))
}, [])
function burst() {
timers.current.forEach((id) => window.clearTimeout(id))
timers.current = Array.from({ length: 10 }, (_, index) =>
window.setTimeout(
() => setValue((current) => current + Math.ceil(Math.random() * 40)),
index * 90
)
)
}
return (
<div className="flex flex-col items-center gap-4">
<NumberFlow
value={value}
onAnimationsStart={() => setStatus("Animating")}
onAnimationsFinish={() => setStatus("Settled")}
className="text-5xl font-semibold"
/>
<span className="text-sm text-muted-foreground">{status}</span>
<Button variant="outline" size="sm" onClick={burst}>
Burst ×10
</Button>
</div>
)
}Inline and in badges#
It renders an inline <span> that sits on the text baseline, and tabular digits keep its width steady while digits spin. BadgeCount uses it for counts that cap at a maximum.
You have 88012345678901234567890123456789 new messages and the baseline stays aligned with the text.
"use client"
import * as React from "react"
import { IconInbox } from "@tabler/icons-react"
import { Badge, BadgeCount } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
export function NumberFlowInline() {
const [value, setValue] = React.useState(8)
return (
<div className="flex w-full max-w-md flex-col gap-4">
<p className="text-sm">
You have <NumberFlow value={value} className="font-semibold" /> new
messages and the baseline stays aligned with the text.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline">
<IconInbox />
Inbox
<Badge size="sm" appearance="solid" variant="destructive">
<BadgeCount value={value} />
</Badge>
</Button>
<Badge>
<BadgeCount value={value * 7} max={999} />
unread
</Badge>
<Button variant="outline" size="sm" onClick={() => setValue(value + 1)}>
+1
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setValue(value + 50)}
>
+50
</Button>
</div>
</div>
)
}Right to left#
Numbers always read left to right, even inside a right-to-left layout, matching how browsers render them.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
export function NumberFlowRtl() {
const [value, setValue] = React.useState(1250)
return (
<div dir="rtl" className="flex flex-col items-center gap-4">
<NumberFlow
value={value}
locales="ar-EG"
className="text-3xl font-semibold"
/>
<Button variant="outline" size="sm" onClick={() => setValue(value + 17)}>
١٧+
</Button>
</div>
)
}- Screen readers read the real formatted number. The spinning columns are hidden from assistive tech, and characters that are leaving are hidden while they animate out.
- It is not a live region. Wrap it in an element with
aria-live="polite"when changes should be announced. - With reduced motion enabled, values update instantly with no spin.
Accepts every <span> attribute except children and prefix.
| Prop | Type | Default |
|---|---|---|
valueRequired. | number | – |
locales | Intl.LocalesArgument | "en-US" |
formatPassed to Intl.NumberFormat. | Intl.NumberFormatOptions | – |
prefixStatic text before the number. | string | – |
suffixStatic text after the number. | string | – |
trend | "auto" | "up" | "down" | "shortest" | "auto" |
durationSpin duration in milliseconds. | number | 600 |
easingAny CSS easing value. | string | easeSpring |
animated | boolean | true |
onAnimationsStartCalled when a batch of digits starts moving. | () => void | – |
onAnimationsFinishCalled when the latest batch settles. Skipped if a newer value interrupts it. | () => void | – |
| Attribute | Description |
|---|---|
data-slot="number-flow" | The root span. |
data-slot="number-flow-digit" | Each digit cell. |
data-slot="number-flow-symbol" | Separators, signs, symbols, prefix and suffix. |
data-slot="number-flow-column" | The spinning column inside a digit cell. |
data-digit | The digit a column is resting on, 0–9. |