Skip to content
6

Animated Number

Count-up number that tweens to its target with easing, Intl formatting (currency, compact, percent), prefix/suffix and reduced-motion support.

Example

Installation

npx shadcn@latest add https://hirael.com/r/animated-number.json

Usage

import { AnimatedNumber } from "@/components/animated-number"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<AnimatedNumber />

+ native element props
PropTypeDefault
value*

Target value to animate toward.

numbernone
startValue

Value the first animation starts from. Defaults to 0.

number0
duration

Tween length in milliseconds.

number700
decimals

Fixed number of decimal places.

number0
format

Extra `Intl.NumberFormat` options (currency, notation, …).

Intl.NumberFormatOptionsnone
localestringnone
prefixstringnone
suffixstringnone

Component source

'use client';

import * as React from 'react';

import { cn } from '@/lib/utils';

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;
  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,
  prefix,
  suffix,
  className,
  ...props
}: AnimatedNumberProps) => {
  const [display, setDisplay] = React.useState(startValue);
  // Tracks the latest rendered value so an animation interrupted mid-flight
  // resumes from where it visually is, instead of snapping back.
  const displayRef = React.useRef(startValue);
  const frameRef = React.useRef<number | undefined>(undefined);

  const formatter = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
        ...format,
      }),
    [locale, decimals, format],
  );

  React.useEffect(() => {
    const reduceMotion =
      typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

    if (reduceMotion || duration <= 0) {
      displayRef.current = value;
      setDisplay(value);
      return;
    }

    const from = displayRef.current;
    const start = performance.now();

    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) frameRef.current = requestAnimationFrame(tick);
    };

    frameRef.current = requestAnimationFrame(tick);
    return () => {
      if (frameRef.current !== undefined) cancelAnimationFrame(frameRef.current);
    };
  }, [value, duration]);

  const formatted = formatter.format(display);

  return (
    <span
      data-slot="animated-number"
      className={cn('tabular-nums', className)}
      aria-label={`${prefix ?? ''}${formatter.format(value)}${suffix ?? ''}`}
      {...props}
    >
      {prefix}
      <span aria-hidden>{formatted}</span>
      {suffix}
    </span>
  );
};

export { AnimatedNumber };