Skip to content
Corsair UI
Browse the docs

TextNo. 68 of 109

Number Ticker

A number that counts up to its value when it comes into view, formatted for the locale.

Installation

pnpm dlx shadcn@latest add @corsair-ui/number-ticker

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsuse-in-viewuse-entrance
npm
clsxtailwind-merge

Examples

Formats

Intl.NumberFormat options; the server HTML already has the final value.

60M$1,850.5094%

API

PropTypeDefault
value

Where it ends.

number—
from

Where it starts.

number0
duration / delay

In ms.

number1600 / 0
locale / format

How the number is written.

string / Intl.NumberFormatOptions"en-US"
trigger / once / play

When it counts.

"load" | "in-view" / boolean / boolean"in-view" / true

Accessibility

  • Screen readers get the final value once; the moving digits are hidden from them.
  • The server HTML has the final value. With prefers-reduced-motion it holds still and shows the finished state.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

1 file, installed as source you own
number-ticker.tsxView on GitHub
"use client";

import { useEffect, useMemo, useRef, type ComponentProps, type Ref } from "react";
import { useEntrance, type EntranceTrigger } from "@/registry/default/hooks/use-entrance";
import { cn } from "@/registry/default/lib/utils";

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    }
  };
}

function prefersReducedMotion() {
  return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}

/** Digits after the decimal point, so 12.5 counts in tenths rather than in noisy thousandths. */
function decimalsOf(value: number) {
  if (!Number.isFinite(value)) return 0;
  const [, fraction = ""] = String(value).split(".");
  return Math.min(fraction.length, 20);
}

/**
 * Options for the formatter. Without any digit settings of their own, plain
 * numbers keep the precision of `value` and `from` on every frame, so the
 * width does not jump between frames.
 */
function formatOptions(value: number, from: number, format?: Intl.NumberFormatOptions) {
  const style = format?.style ?? "decimal";
  const hasDigits =
    format?.minimumFractionDigits !== undefined ||
    format?.maximumFractionDigits !== undefined ||
    format?.minimumSignificantDigits !== undefined ||
    format?.maximumSignificantDigits !== undefined;
  if (hasDigits || (style !== "decimal" && style !== "unit")) return format;
  const decimals = Math.max(decimalsOf(value), decimalsOf(from));
  return { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...format };
}

const easeOutCubic = (t: number) => 1 - (1 - t) ** 3;

interface NumberTickerProps extends Omit<ComponentProps<"span">, "children"> {
  /** The number it counts to. */
  value: number;
  /** The number it counts from. */
  from?: number;
  /** Length of the count, in ms. */
  duration?: number;
  /** Wait before it starts, in ms. */
  delay?: number;
  /** Locale for the digits, separators and currency. */
  locale?: string;
  /** Passed to `Intl.NumberFormat`: currency, percent, compact notation, digits… */
  format?: Intl.NumberFormatOptions;
  /** "load" counts as soon as the page runs; "in-view" when the number scrolls into view. */
  trigger?: EntranceTrigger;
  /** In-view: count the first time only. */
  once?: boolean;
  /** Takes over from `trigger`: `false` holds it at `from`, `true` counts. */
  play?: boolean;
}

/**
 * A number that counts up (or down) to its value with an ease-out. It
 * writes straight to the text once per frame instead of re-rendering
 * React. The server HTML holds the final, formatted value for search
 * engines and browsers without JavaScript; screen readers only ever hear
 * the final value, since the moving digits are hidden from them; and with
 * `prefers-reduced-motion` the final value just shows. Digits are tabular,
 * so the width holds still while it counts.
 *
 * @example
 * <NumberTicker value={12840} format={{ style: "currency", currency: "USD" }} />
 */
function NumberTicker({
  value,
  from = 0,
  duration = 1600,
  delay = 0,
  locale = "en-US",
  format,
  trigger = "in-view",
  once = true,
  play,
  className,
  ref,
  ...props
}: NumberTickerProps) {
  const [observe, phase] = useEntrance<HTMLSpanElement>({ trigger, once, play });
  const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);
  const output = useRef<HTMLSpanElement>(null);

  // A new `format` object on every render must not restart the count.
  const optionsKey = JSON.stringify(formatOptions(value, from, format) ?? null);
  const formatter = useMemo(
    () =>
      new Intl.NumberFormat(
        locale,
        (JSON.parse(optionsKey) as Intl.NumberFormatOptions | null) ?? undefined
      ),
    [locale, optionsKey]
  );
  const finalText = formatter.format(value);

  useEffect(() => {
    const node = output.current;
    if (!node) return;
    // Change the value of React's own text node rather than replacing it,
    // so React's later updates still land on the node that is on screen.
    const write = (text: string) => {
      const child = node.firstChild;
      if (child?.nodeType === Node.TEXT_NODE) child.nodeValue = text;
      else node.textContent = text;
      node.style.visibility = "visible";
    };
    const settle = () => write(formatter.format(value));
    if (phase === "static" || prefersReducedMotion()) return settle();
    if (phase === "armed") return write(formatter.format(from));

    const length = Math.max(duration, 1);
    write(formatter.format(from));
    // Timed from the first frame's own timestamp, so it never mixes clocks.
    let start: number | undefined;
    let id = 0;
    const tick = (now: number) => {
      start ??= now + delay;
      const progress = Math.min(Math.max((now - start) / length, 0), 1);
      if (progress >= 1) return settle();
      write(formatter.format(from + (value - from) * easeOutCubic(progress)));
      id = requestAnimationFrame(tick);
    };
    id = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(id);
  }, [phase, formatter, value, from, duration, delay]);

  return (
    <span
      ref={mergedRef}
      data-slot="number-ticker"
      data-state={phase}
      className={cn("inline-block tabular-nums", className)}
      {...props}
    >
      <span className="sr-only">{finalText}</span>
      {/* Hidden until the script takes over when it is about to play, so the final value does not flash first; shown as is without JavaScript. */}
      <span
        ref={output}
        aria-hidden="true"
        data-slot="number-ticker-value"
        className={cn(phase === "play" && "[@media(scripting:enabled)]:invisible")}
      >
        {finalText}
      </span>
    </span>
  );
}

export { NumberTicker, type NumberTickerProps };