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.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook2 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 = new 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";
typescript