A number that animates to new values: digits roll like an odometer, or the whole number counts up. Formats with Intl for currency, percent and more.
pnpm dlx shadcn@latest add @latheui/animated-numberFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/playback.tslib/motion.tsCopy the source into your project.
"use client"
import { usePreviewAnimation } from "@/lib/playback"
import * as React from "react"
import { animate, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { ease, spring, useMotionSafe } from "@/lib/motion"
export type AnimatedNumberProps = Omit<React.ComponentProps<"span">, "children"> & {
value: number
/** `Intl.NumberFormat` options, for example `{ style: "currency", currency: "USD" }`. */
format?: Intl.NumberFormatOptions
/** BCP 47 locale. Fixed by default so server and client render the same text. */
locale?: string
/**
* How a change shows. `roll` turns each digit's column to its new value. `count` runs the
* whole number through every value in between.
*/
animation?: "roll" | "count"
/** With `count`, the number shown first, counted up (or down) from as the component mounts. */
from?: number
/** With `count`, seconds a change takes. */
duration?: number
}
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
/** A number whose digits roll to their new values when it changes. */
function AnimatedNumber({
value,
format,
locale = "en-US",
animation = "roll",
from,
duration = 1.6,
className,
...props
}: AnimatedNumberProps) {
const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format])
const text = formatter.format(value)
return (
<span
data-slot="animated-number"
data-animation={animation}
className={cn("inline-flex leading-none tabular-nums", className)}
{...props}
>
{/* Assistive tech reads the plain value once, not ten digits per column or every step of a count. */}
<span className="sr-only">{text}</span>
{animation === "count" ? (
<Count value={value} from={from} duration={duration} formatter={formatter} />
) : (
<Roll text={text} />
)}
</span>
)
}
function Roll({ text }: { text: string }) {
const { safe } = useMotionSafe()
const chars = Array.from(text)
return (
// Each column is exactly one line tall. The line height is pinned to 1em here, whatever text size the
// caller sets, so no glyph pokes past its cell and a neighbouring digit never peeks into the window.
<span aria-hidden="true" className="inline-flex leading-[1em]">
{chars.map((char, index) => {
// Keyed from the right, so the ones column stays the ones column as digits are added.
const key = chars.length - index
const digit = DIGITS.indexOf(char)
// The markup is the same with reduced motion (only the transition changes),
// so server and client always render the same thing.
if (digit === -1) {
return (
<span key={`${key}-${char}`} className="inline-block whitespace-pre">
{char}
</span>
)
}
return (
<span key={key} className="inline-block h-[1em] overflow-hidden">
<motion.span
initial={false}
animate={{ y: `${-digit * 10}%` }}
transition={safe(spring.snappy)}
className="flex flex-col"
>
{DIGITS.map((d) => (
<span key={d} className="block h-[1em]">
{d}
</span>
))}
</motion.span>
</span>
)
})}
</span>
)
}
/** Decimal places written in a number, so a count to 12.5 shows tenths and a count to 1,280 none. */
function places(value: number) {
const [, fraction = ""] = String(value).split(".")
return fraction.length
}
function Count({
value,
from,
duration,
formatter,
}: {
value: number
from?: number
duration: number
formatter: Intl.NumberFormat
}) {
const { reduced } = useMotionSafe()
const track = usePreviewAnimation()
const ref = React.useRef<HTMLSpanElement>(null)
// Where the count stands. A change made mid-count carries on from there.
const shown = React.useRef(from ?? value)
// React writes the first text only. After that each frame writes it, so a new value never flashes in early.
const [initial] = React.useState(() => formatter.format(from ?? value))
// A `format` written inline is a new object every render. That alone must not restart the count.
const latest = React.useRef(formatter)
React.useEffect(() => {
latest.current = formatter
})
React.useEffect(() => {
const node = ref.current
if (!node) return
const write = (current: number) => {
shown.current = current
const factor = 10 ** places(value)
node.textContent = latest.current.format(Math.round(current * factor) / factor)
}
if (reduced) {
write(value)
return
}
const controls = animate(shown.current, value, { duration, ease: ease.out, onUpdate: write })
const untrack = track(controls)
return () => { untrack(); controls.stop() }
}, [value, duration, reduced, track])
return (
<span ref={ref} aria-hidden="true" className="whitespace-pre">
{initial}
</span>
)
}
export { AnimatedNumber }Depends on
Also accepts native span attributes. Font size and weight come from className.
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | number | — | The number to display. |
| format | Intl.NumberFormatOptions | — | Formatting options, such as currency, percent or fraction digits. |
| animation | "roll" | "count" | "roll" | roll turns each digit's column to its new value. count runs the whole number through the values in between. |
| from | number | — | With count, the value shown first, counted from as the component mounts. Without it the number starts at value. |
| duration | number | 1.6 | With count, seconds each change takes. |
| locale | string | "en-US" | Locale for grouping and symbols. Fixed by default so server and client output match. |
<p aria-live="polite">
<AnimatedNumber value={unread} /> unread
</p><AnimatedNumber value={1284000} format={{ notation: "compact", maximumFractionDigits: 1 }} />