shadimage
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 · light

Registry 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 };

More from hirael

Browse all components in this library →