Skip to content
Uxelith
Sign in
Freev1.0.0Data

Number Ticker

A number whose digits roll into place like an odometer: each column turns on its own, new places slide in and separators make room. Currency, percent, compact and every locale.

pnpm dlx shadcn add …/r/number-ticker.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/number-ticker.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as number-ticker.tsx. It's yours: change anything.

Usage

example.tsx
import { NumberTicker } from "@/components/number-ticker"

export function Revenue() {
  return (
    <NumberTicker
      value={48210}
      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
      className="text-4xl font-semibold"
    />
  )
}

Source

One file, 145 lines, nothing hidden. Read it before you install it.

number-ticker.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

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

export interface NumberTickerProps extends Omit<React.ComponentProps<"span">, "children"> {
  value: number;
  /** Formatting, e.g. `{ style: "currency", currency: "USD" }` or `{ maximumFractionDigits: 1 }`. */
  format?: Intl.NumberFormatOptions;
  /** BCP 47 locale, e.g. "en-IN" for 1,00,000. Defaults to the visitor's. */
  locale?: string;
  /** Seconds each digit takes to roll to its new place. */
  duration?: number;
  /** Wait until the number scrolls into view before rolling up from zero. */
  startOnView?: boolean;
}

// Motion presets (ADR-010).
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

type Part = { key: string; char: string; digit?: number };

const noop = () => () => {};

/**
 * Splits a formatted number into characters keyed by their place, counted
 * from the decimal point, so "999" → "1,000" keeps the ones, tens and
 * hundreds columns and only adds the new ones.
 */
function toParts(formatter: Intl.NumberFormat, value: number): Part[] {
  const parts = formatter.formatToParts(value);
  const integerDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0);
  let place = integerDigits;
  let fraction = 0;
  let afterDecimal = false;
  const out: Part[] = [];
  parts.forEach((part, partIndex) => {
    for (const char of part.value) {
      if (part.type === "integer") {
        place -= 1;
        out.push({ key: `i${place}`, char, digit: Number(char) });
      } else if (part.type === "fraction") {
        out.push({ key: `f${fraction++}`, char, digit: Number(char) });
      } else {
        if (part.type === "decimal") afterDecimal = true;
        // Group separators sit between places; other symbols keep their slot in the format.
        const key = part.type === "group" ? `g${place}` : `${part.type}${afterDecimal ? "-after" : ""}-${partIndex}`;
        out.push({ key, char });
      }
    }
  });
  return out;
}

/** One digit as a vertical strip of 0–9 that rolls to show `digit`. */
function Column({ digit, transition }: { digit: number; transition: object }) {
  return (
    <span className="relative inline-block h-[1lh] overflow-hidden" data-part={digit}>
      {/* Takes the digit's width; tabular figures make every digit the same. */}
      <span className="invisible">0</span>
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        animate={{ y: `${-digit * 10}%` }}
        transition={transition}
      >
        {DIGITS.map((d) => (
          <span key={d} className="block h-[1lh] text-center">
            {d}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

/**
 * A number whose digits roll into place like an odometer: each column turns
 * on its own, new places slide in, and separators shift to make room.
 *
 * - Formatted with `Intl.NumberFormat`: currencies, percentages, compact
 *   ("12K") and every locale's grouping.
 * - Digits are tabular, so the width doesn't jitter while rolling.
 * - Screen readers read only the final value; the rolling is hidden from them.
 * - Under `prefers-reduced-motion` it shows the value immediately.
 */
export function NumberTicker({
  value,
  format,
  locale,
  duration = 0.9,
  startOnView = true,
  className,
  ...props
}: NumberTickerProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const reduceMotion = useReducedMotion();
  const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);
  const [visible, setVisible] = React.useState(!startOnView);

  // False on the server and while hydrating, true after: the server can't
  // know about reduced motion, so the value appears right after hydration.
  const hydrated = React.useSyncExternalStore(noop, () => true, () => false);

  React.useEffect(() => {
    if (visible || reduceMotion || !ref.current) return;
    const io = new IntersectionObserver(([entry]) => entry?.isIntersecting && setVisible(true), { threshold: 0.5 });
    io.observe(ref.current);
    return () => io.disconnect();
  }, [visible, reduceMotion]);

  const shown = visible || (reduceMotion && hydrated) ? value : 0;
  const parts = toParts(formatter, shown);
  const roll = reduceMotion ? { duration: 0 } : { duration, ease: [0.22, 1, 0.36, 1] };

  return (
    <span ref={ref} className={cn("inline-block tabular-nums", className)} {...props}>
      <span
        aria-hidden="true"
        // A soft fade at the top and bottom edge, like digits on a drum.
        className="inline-flex whitespace-nowrap leading-[1.2] [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]"
      >
        <AnimatePresence initial={false} mode="popLayout">
          {parts.map((part) => (
            <motion.span
              key={part.key}
              layout={reduceMotion ? false : "position"}
              initial={{ opacity: 0, y: "-0.5em", filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, y: "0.5em", filter: "blur(2px)" }}
              transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
              className="inline-block"
            >
              {part.digit === undefined ? <span data-part={part.char}>{part.char}</span> : <Column digit={part.digit} transition={roll} />}
            </motion.span>
          ))}
        </AnimatePresence>
      </span>
      <span className="sr-only">{formatter.format(value)}</span>
    </span>
  );
}

Props

PropTypeDefaultDescription
valuenumber—The number to show.
formatIntl.NumberFormatOptions—Formatting, e.g. { style: "percent" } or { notation: "compact" }.
localestringvisitor's localeBCP 47 locale, e.g. "en-IN" for 1,00,000.
durationnumber0.9Seconds each digit takes to roll to its new place.
startOnViewbooleantrueWait until it scrolls into view before rolling up from zero.

Accessibility

  • Screen readers read only the final, formatted value; the rolling digits are aria-hidden.
  • Digits are tabular, so the width doesn't jitter while rolling.
  • Shows the value immediately under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Data

All 9 in Data
  • DataPro

    Area Chart

    Trends over time: lines draw themselves in, the crosshair glides between points, the area you've scrubbed stays bright, and the tooltip shows the change from the point before.

  • DataFree

    Avatar Stack

    Overlapping avatars that spread on hover, with a name tooltip that swings with the pointer, status dots and a +N list of everyone else. Photos or initials.

  • DataPro

    Bar Chart

    A responsive bar chart, grouped or stacked: bars rise in one after another, the hovered column lights up and the tooltip glides, with keyboard navigation and a hidden data table.