Number roll
A number that rolls to its new value when it changes, and never animates on mount.
The formatted final value is what renders on the server and on the first client render, so a page never spends its opening frames showing numbers that are not true — unlike a count-up, which starts at zero. Only a change animates, and it animates from wherever the roll had got to, so a value that moves again mid-roll continues instead of snapping back. The duration defaults to --duration-slow read off the document, which is 0ms both under prefers-reduced-motion and under [data-motion="reduced"] — so there is one switch for motion rather than a prop per component. The moving text is aria-hidden beside an sr-only copy of the destination, because a number read aloud every frame is a stream of wrong values.
Install
npx shadcn@latest add @vibra/number-rollNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { NumberRoll } from "@/components/ui/number-roll"
import { formatCurrency } from "@/lib/format"
export default function NumberRollDemo() {
const [requests, setRequests] = React.useState(48210)
const [revenue, setRevenue] = React.useState(184300)
return (
<div className="flex w-full max-w-md flex-col gap-4 panel p-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Requests today</span>
<NumberRoll value={requests} className="text-2xl font-semibold tracking-tight" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Booked revenue</span>
<NumberRoll
value={revenue}
format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
className="text-2xl font-semibold tracking-tight"
/>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => {
setRequests((count) => count + 1200 + Math.round(Math.random() * 4000))
setRevenue((amount) => amount + 3500 + Math.round(Math.random() * 9000))
}}
>
Refresh
</Button>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The number to show, and to roll to when it changes. |
| format | (value: number) => string | whole units with separators | Receives the animating value every frame, so round inside it. |
| duration | number | --duration-slow (320ms) | Milliseconds. 0 lands immediately, which is what reduced motion resolves to. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
/** Rounds to whole units — the roll passes fractional values through every frame. */
function formatWhole(value: number) {
return formatNumber(value, { maximumFractionDigits: 0 })
}
function easeOutCubic(t: number) {
return 1 - (1 - t) ** 3
}
/** The theme's own slow duration, in milliseconds; the literal is the fallback for an install without the theme. */
const FALLBACK_DURATION = 320
/**
* `--duration-slow`, resolved at the moment the value changes rather than read
* once: the token is zeroed both by `prefers-reduced-motion` and by
* `[data-motion="reduced"]`, so asking the document for it is what makes this
* component honour either without a second code path.
*/
function tokenDuration(): number {
if (typeof window === "undefined") return FALLBACK_DURATION
const raw = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--duration-slow")
.trim()
const seconds = raw.endsWith("ms") ? 0.001 : raw.endsWith("s") ? 1 : 0
const parsed = Number.parseFloat(raw)
if (!seconds || !Number.isFinite(parsed)) return FALLBACK_DURATION
return parsed * seconds * 1000
}
/** The OS-level setting, for a project that installed the item without the theme's tokens. */
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
// `children` is what this renders, so a caller passing any would be ignored.
export type NumberRollProps = Omit<React.ComponentProps<"span">, "children"> & {
value: number
/** Receives the animating value every frame, so round inside it. */
format?: (value: number) => string
/** Milliseconds. Defaults to --duration-slow, which is 0ms under reduced motion. */
duration?: number
}
/**
* A number that rolls to its new value when it changes.
*
* The formatted final value is what renders on the server and on the first
* client render — nothing animates on mount, so a page does not spend its
* first frames showing numbers that are not yet true. Only a change animates,
* from wherever the roll had got to, so a value that moves again mid-roll
* continues rather than jumping back.
*/
function NumberRoll({
className,
value,
format = formatWhole,
duration,
...props
}: NumberRollProps) {
// Only a roll in flight has state of its own; the rest of the time the
// number shown is the number given, which is what makes the first render —
// on the server and in the browser — the final value with nothing to correct.
const [rolling, setRolling] = React.useState<number | null>(null)
// Where the roll actually is, which is not `value` while one is running.
const at = React.useRef(value)
const display = rolling ?? value
React.useEffect(() => {
const from = at.current
if (from === value) return
const ms = duration ?? tokenDuration()
if (ms <= 0 || prefersReducedMotion()) {
at.current = value
// Nothing to animate, and `display` already reads `value`. The frame
// exists only to drop a roll that was in flight when the reader turned
// motion off; with none in flight it changes nothing.
const clear = requestAnimationFrame(() => setRolling(null))
return () => cancelAnimationFrame(clear)
}
let frame = 0
const start = performance.now()
const step = (now: number) => {
const progress = Math.min(1, (now - start) / ms)
if (progress === 1) {
at.current = value
setRolling(null)
return
}
const next = from + (value - from) * easeOutCubic(progress)
at.current = next
setRolling(next)
frame = requestAnimationFrame(step)
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [value, duration])
return (
<span
data-slot="number-roll"
data-rolling={display !== value || undefined}
className={cn("tabular-nums", className)}
{...props}
>
{/* A number in motion is decoration: read aloud it would be a stream of
wrong values. The one that is true sits beside it, always. */}
<span aria-hidden="true">{format(display)}</span>
<span className="sr-only">{format(value)}</span>
</span>
)
}
export { NumberRoll }