Text Effects

Number Ticker

Animates digits from a start value to a target with a spring, rendered in tabular-nums so the layout never shifts.

Install

npx shadcn@latest add @paragon/number-ticker

number-ticker.tsx

"use client";

import * as React from "react";
import {
  useInView,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";

export interface NumberTickerProps extends React.ComponentProps<"span"> {
  /** Target value the ticker settles on. */
  value: number;
  /** Value the spring starts from. */
  startValue?: number;
  /** Seconds to wait before the spring starts — useful for staggering groups. */
  delay?: number;
  /** Perceived spring duration in seconds. */
  duration?: number;
  /** Locale for Intl.NumberFormat. */
  locale?: Intl.LocalesArgument;
  /** Intl.NumberFormat options — currency, notation, fraction digits, etc. */
  formatOptions?: Intl.NumberFormatOptions;
  /** Renders the target value immediately, no animation. */
  static?: boolean;
}

/**
 * Animates a number from a start value to a target on a spring (bounce 0),
 * formatted through Intl.NumberFormat. Rendered in tabular-nums so digit
 * widths are fixed and surrounding layout never shifts mid-count. Starts
 * when scrolled into view; under prefers-reduced-motion (or `static`) it
 * jumps straight to the target.
 */
export function NumberTicker({
  value,
  startValue = 0,
  delay = 0,
  duration = 0.5,
  locale = "en-US",
  formatOptions,
  static: isStatic = false,
  className,
  ...props
}: NumberTickerProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });

  // Fraction digits inferred from the target so intermediate spring values
  // never flash extra decimals; explicit formatOptions always win.
  const inferredDigits = React.useMemo(() => {
    if (Number.isInteger(value)) return 0;
    const decimals = String(value).split(".")[1];
    return Math.min(decimals?.length ?? 0, 3);
  }, [value]);

  const serializedOptions = JSON.stringify(formatOptions);
  const format = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: inferredDigits,
        maximumFractionDigits: inferredDigits,
        ...formatOptions,
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [locale, inferredDigits, serializedOptions],
  );

  const spring = useSpring(isStatic ? value : startValue, {
    duration: duration * 1000,
    bounce: 0,
  });

  useMotionValueEvent(spring, "change", (latest) => {
    if (ref.current) ref.current.textContent = format.format(latest);
  });

  React.useEffect(() => {
    if (isStatic || !inView) return;
    if (reducedMotion) {
      spring.jump(value);
      return;
    }
    if (delay > 0) {
      const timeout = setTimeout(() => spring.set(value), delay * 1000);
      return () => clearTimeout(timeout);
    }
    spring.set(value);
  }, [inView, value, delay, reducedMotion, isStatic, spring]);

  return (
    <span
      ref={ref}
      data-slot="number-ticker"
      className={cn("inline-block tabular-nums", className)}
      {...props}
    >
      {format.format(isStatic ? value : startValue)}
    </span>
  );
}