hirael
componentanimated-numbersource: ok · hit
Animated Number
Count-up number that tweens to its target with easing, Intl formatting (currency, compact, percent), prefix/suffix and reduced-motion support.
npx shadcn add "https://hirael.com/r/animated-number.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://hirael.com/r/use-reduced-motion.json
Files1 · source cached 10/15/2026, 8:12:41 AM
registry/hirael/bases/radix/components/animated-number.tsx
// Animated Number from Hirael <https://hirael.com/components/data/animated-number>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useReducedMotion } from '@/registry/hirael/hooks/use-reduced-motion';
export interface AnimatedNumberProps extends Omit<React.ComponentProps<'span'>, 'children'> {
/** Target value to animate toward. */
value: number;
/** Value the first animation starts from. Defaults to 0. */
startValue?: number;
/** Tween length in milliseconds. */
duration?: number;
/** Fixed number of decimal places. */
decimals?: number;
/** Extra `Intl.NumberFormat` options (currency, notation, …). */
format?: Intl.NumberFormatOptions;
/** Defaults to `en-US` so server and client render the same digits. */
locale?: string;
prefix?: string;
suffix?: string;
}
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const AnimatedNumber = ({
value,
startValue = 0,
duration = 700,
decimals = 0,
format,
locale = 'en-US',
prefix,
suffix,
className,
...props
}: AnimatedNumberProps) => {
const [display, setDisplay] = React.useState(startValue);
const displayRef = React.useRef(startValue);
const formatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
...format,
}),
[locale, decimals, format],
);
const animated = !useReducedMotion() && duration > 0;
React.useLayoutEffect(() => {
if (!animated) displayRef.current = value;
});
React.useEffect(() => {
if (!animated) return;
const from = displayRef.current;
const start = performance.now();
let frame: number;
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
const next = t < 1 ? from + (value - from) * easeOutCubic(t) : value;
displayRef.current = next;
setDisplay(next);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [value, duration, animated]);
const formatted = formatter.format(animated ? display : value);
return (
<span data-slot="animated-number" className={cn('tabular-nums', className)} {...props}>
<span className="sr-only">{`${prefix ?? ''}${formatter.format(value)}${suffix ?? ''}`}</span>
<span aria-hidden>
{prefix}
{formatted}
{suffix}
</span>
</span>
);
};
export { AnimatedNumber };