Number Ticker

Animated number transitions for stats, KPIs, and compact numeric callouts.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/number-ticker.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import * as React from "react"; import { cn } from "../../lib/utils"; export type NumberTickerProps = React.ComponentPropsWithoutRef<"span"> & { delay?: number; duration?: number; formatOptions?: Intl.NumberFormatOptions; from?: number; locale?: string; value: number; }; const NUMBER_FORMATTER_CACHE = new Map<string, Intl.NumberFormat>(); function getNumberTickerFormatter( locale: string | undefined, formatOptions: Intl.NumberFormatOptions | undefined, ): Intl.NumberFormat { const key = `${locale ?? ""}|${formatOptions ? JSON.stringify(formatOptions) : ""}`; let formatter = NUMBER_FORMATTER_CACHE.get(key); if (!formatter) { formatter = Intl.NumberFormat(locale, formatOptions); NUMBER_FORMATTER_CACHE.set(key, formatter); } return formatter; } export const NumberTicker = React.forwardRef< HTMLSpanElement, NumberTickerProps >( ( { className, delay = 0, duration = 1.2, formatOptions, from = 0, locale, value, ...props }, ref, ) => { const [currentValue, setCurrentValue] = React.useState(from); React.useEffect(() => { const reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reducedMotion || duration <= 0) { setCurrentValue(value); return; } let animationFrame = 0; let timeoutId = 0; const startDelay = Math.max(0, delay * 1000); const durationMs = duration * 1000; timeoutId = window.setTimeout(() => { const startTime = performance.now(); const tick = (timestamp: number) => { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / durationMs, 1); const nextValue = from + (value - from) * progress; setCurrentValue(nextValue); if (progress < 1) { animationFrame = window.requestAnimationFrame(tick); } }; animationFrame = window.requestAnimationFrame(tick); }, startDelay); return () => { window.clearTimeout(timeoutId); window.cancelAnimationFrame(animationFrame); }; }, [delay, duration, from, value]); const formatter = getNumberTickerFormatter(locale, formatOptions); return ( <span className={cn("tabular-nums tracking-tight", className)} ref={ref} {...props} > {formatter.format(currentValue)} </span> ); }, ); NumberTicker.displayName = "NumberTicker";

Dependencies

  • @vllnt/ui@^0.2.1