Animates digits from a start value to a target with a spring, rendered in tabular-nums so the layout never shifts.
npx shadcn@latest add @paragon/number-ticker"use client";
import * as React from "react";
import {
useInView,
useMotionValueEvent,
useReducedMotion,
useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface NumberTickerProps extends React.ComponentProps<"span"> {
/** Target value the ticker settles on. */
value: number;
/** Value the spring starts from. */
startValue?: number;
/** Seconds to wait before the spring starts — useful for staggering groups. */
delay?: number;
/** Perceived spring duration in seconds. */
duration?: number;
/** Locale for Intl.NumberFormat. */
locale?: Intl.LocalesArgument;
/** Intl.NumberFormat options — currency, notation, fraction digits, etc. */
formatOptions?: Intl.NumberFormatOptions;
/** Renders the target value immediately, no animation. */
static?: boolean;
}
/**
* Animates a number from a start value to a target on a spring (bounce 0),
* formatted through Intl.NumberFormat. Rendered in tabular-nums so digit
* widths are fixed and surrounding layout never shifts mid-count. Starts
* when scrolled into view; under prefers-reduced-motion (or `static`) it
* jumps straight to the target.
*/
export function NumberTicker({
value,
startValue = 0,
delay = 0,
duration = 0.5,
locale = "en-US",
formatOptions,
static: isStatic = false,
className,
...props
}: NumberTickerProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
// Fraction digits inferred from the target so intermediate spring values
// never flash extra decimals; explicit formatOptions always win.
const inferredDigits = React.useMemo(() => {
if (Number.isInteger(value)) return 0;
const decimals = String(value).split(".")[1];
return Math.min(decimals?.length ?? 0, 3);
}, [value]);
const serializedOptions = JSON.stringify(formatOptions);
const format = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: inferredDigits,
maximumFractionDigits: inferredDigits,
...formatOptions,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale, inferredDigits, serializedOptions],
);
const spring = useSpring(isStatic ? value : startValue, {
duration: duration * 1000,
bounce: 0,
});
useMotionValueEvent(spring, "change", (latest) => {
if (ref.current) ref.current.textContent = format.format(latest);
});
React.useEffect(() => {
if (isStatic || !inView) return;
if (reducedMotion) {
spring.jump(value);
return;
}
if (delay > 0) {
const timeout = setTimeout(() => spring.set(value), delay * 1000);
return () => clearTimeout(timeout);
}
spring.set(value);
}, [inView, value, delay, reducedMotion, isStatic, spring]);
return (
<span
ref={ref}
data-slot="number-ticker"
className={cn("inline-block tabular-nums", className)}
{...props}
>
{format.format(isStatic ? value : startValue)}
</span>
);
}