Skip to content

Rolls each digit toward its new value in the direction it moved, tinting briefly.

Numbers & charts@number-flow/reactmotion

01Preview

Revenue today
$48,210.40
Orders
1,284
Store visits
0
Order #4821 · Linen shirt

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @number-flow/react motion

03Usage

import { NumberTicker } from "@/components/ui/number-ticker";

<NumberTicker value={48210.4} currency="USD" flash />

// Compact, counting up the first time it scrolls into view.
<NumberTicker value={48316} compact from={0} />

04Source

"use client";
import NumberFlow, { type Format } from "@number-flow/react";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";

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

/** The reader's locale on the client, a fixed one on the server, so hydration always matches. */
export function useLocale(locale?: string) {
  const detected = useSyncExternalStore(
    noop,
    () => Intl.NumberFormat().resolvedOptions().locale,
    () => "en-US",
  );
  return locale ?? detected;
}

export type NumberFormatShortcuts = {
  /** ISO 4217 code. Formats as money with the narrow symbol: "$", "€", "¥". */
  currency?: string;
  /** Short scale: 1.2K, 48.2M. One decimal at most. */
  compact?: boolean;
  /** Fixed fraction digits, so a value never jumps between "4.1" and "4.10". */
  decimals?: number;
  /** Any Intl.NumberFormat option, applied last. Scientific notation isn't supported. */
  format?: Format;
};

/** Turns the shortcuts into one Intl options object. Shared by every number in the data category. */
export function numberFormatOptions({ currency, compact, decimals, format }: NumberFormatShortcuts): Format {
  return {
    ...(currency ? { style: "currency", currency, currencyDisplay: "narrowSymbol" } : null),
    ...(compact ? { notation: "compact", maximumFractionDigits: 1 } : null),
    ...(decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : null),
    ...format,
  };
}

const expo = `cubic-bezier(${ease.out.join(",")})`;
// Everyday changes settle in 600ms; the first count-up from `from` is given longer to travel.
const timing = (ms: number) => ({
  transformTiming: { duration: ms, easing: expo },
  spinTiming: { duration: ms, easing: expo },
  opacityTiming: { duration: 240, easing: "ease-out" },
});

export type NumberTickerProps = Omit<React.ComponentProps<"span">, "children" | "prefix"> &
  NumberFormatShortcuts & {
    value: number;
    /** BCP 47 tag. Defaults to the reader's locale. */
    locale?: string;
    /** Text that sits inside the number and moves with it: "~", "+". */
    prefix?: string;
    suffix?: string;
    /** Which way digits roll. "auto" rolls up when the value rises and down when it falls. */
    direction?: "auto" | "up" | "down";
    /** Tint the digits success or danger for a moment after each change. */
    flash?: boolean;
    /** For values where down is good (latency, churn, cost): falling flashes success. */
    inverse?: boolean;
    /** Shown until the number first scrolls into view, then it counts to `value`, once. */
    from?: number;
    /** Set false to swap digits without rolling. Reduced motion does this on its own. */
    animated?: boolean;
  };

export function NumberTicker({
  value,
  locale: localeProp,
  currency,
  compact,
  decimals,
  format,
  prefix,
  suffix,
  direction = "auto",
  flash = false,
  inverse = false,
  from,
  animated = true,
  className,
  ref,
  ...rest
}: NumberTickerProps) {
  const locale = useLocale(localeProp);
  const reduce = useReducedMotion();
  const [el, setEl] = useState<HTMLSpanElement | null>(null);
  const [seen, setSeen] = useState(from == null);
  const [finished, setFinished] = useState(false);
  const prev = useRef<number | null>(null);
  const node = useRef<HTMLSpanElement | null>(null);

  const setRefs = useCallback(
    (n: HTMLSpanElement | null) => {
      node.current = n;
      setEl(n);
      if (typeof ref === "function") ref(n);
      else if (ref) ref.current = n;
    },
    [ref],
  );

  // Count up once, the first time at least half of it is on screen.
  useEffect(() => {
    if (seen || !el) return;
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e.isIntersecting) return;
        setSeen(true);
        io.disconnect();
      },
      { threshold: 0.5 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [el, seen]);

  const shown = seen ? value : (from ?? value);
  const rolls = animated && !reduce;
  // The count-up is over once it has landed, or at once when there's nothing to roll.
  const counted = from == null || (seen && (finished || !rolls || from === value));

  // The tint rides on the Web Animations API so a new change restarts it from
  // where it is, and nothing re-renders to fade it out.
  useEffect(() => {
    const last = prev.current;
    prev.current = shown;
    const target = node.current;
    if (last == null || last === shown || !target) return;
    const up = shown > last;
    target.dataset.trend = up ? "up" : "down";
    if (!flash || !counted) return;
    const good = inverse ? !up : up;
    const tint = `var(--${good ? "success" : "danger"})`;
    for (const a of target.getAnimations()) if (a.id === "ticker-tint") a.cancel();
    // No final keyframe: the color eases back to whatever the text inherits.
    const anim = target.animate(
      [
        { color: tint, offset: 0 },
        { color: tint, offset: 0.35 },
      ],
      { duration: 1400, easing: "ease-out" },
    );
    anim.id = "ticker-tint";
  }, [shown, flash, inverse, counted]);

  const trend = direction === "up" ? 1 : direction === "down" ? -1 : undefined;

  return (
    <span
      ref={setRefs}
      data-slot="number-ticker"
      data-counting={!counted || undefined}
      className={cn("inline-flex whitespace-nowrap tabular", className)}
      {...rest}
    >
      <NumberFlow
        value={shown}
        locales={locale}
        format={numberFormatOptions({ currency, compact, decimals, format })}
        prefix={prefix}
        suffix={suffix}
        trend={trend}
        animated={rolls}
        onAnimationsFinish={() => {
          if (seen && !finished) setFinished(true);
        }}
        {...timing(counted ? 600 : 1100)}
      />
    </span>
  );
}

05Props

NumberTicker

PropTypeDefaultDescription
value*numberThe number to show. Every change rolls the digits that differ.
currencystringISO 4217 code. Formats as money with the local symbol and placement.
compactbooleanfalseShort scale in the reader's language: 48.3K, 48,3 k, 1.2M.
decimalsnumberFixed fraction digits, so the width never flickers between 4.1 and 4.10.
formatIntl.NumberFormatOptionsAny other Intl option (percent, units, sign display), applied last.
localestringBCP 47 tag. Defaults to the reader's locale after hydration, en-US on the server.
prefixstringText inside the number that moves with it, such as ~ or +.
suffixstringText after the number that moves with it, such as /mo.
direction"auto" | "up" | "down""auto"Which way the digits roll. Countdowns read better always rolling down.
flashbooleanfalseTint success when it rises and danger when it falls, then fade back.
inversebooleanfalseFor latency, churn or cost: falling tints success.
fromnumberShown until the number is half on screen, then it counts to value once.
animatedbooleantrueSet false to swap digits in place. Reduced motion does this on its own.

numberFormatOptions

PropTypeDefaultDescription
currency | compact | decimals | formatNumberFormatShortcutsThe same shortcuts as one Intl options object, for formatting the value elsewhere (a tooltip, an export).

06Notes

Behavior

  • Formatting is Intl all the way: grouping, decimal marks, currency placement and compact words follow the locale, so 48,210.40 $ in German is not a special case.
  • The server renders en-US and the client switches to the reader's locale after hydration, so the markup always matches.
  • A change that lands mid-roll re-targets from where the digits are; a new flash restarts the tint rather than stacking a second one.
  • With from set, the server and no-JS render show from; the count-up happens once and later changes roll normally.

Motion

  • Digits roll 600ms on the expo ease-out (1100ms for the first count-up), symbols fade over 240ms, and only the digits that changed move.
  • The tint holds for the first 35% of 1.4s and eases back to the inherited color, so a burst of changes reads as one signal.
  • Reduced motion swaps the digits instantly and keeps the tint, because color carries the direction without movement.

Accessibility

  • The number is exposed as an image of its formatted value, so screen readers read $48,210.40 once instead of every digit column.
  • Direction is never color alone: pair flash with a delta or an arrow where it matters, and data-trend is set for styling.
  • It announces nothing on its own; wrap it in a polite live region only when a change needs to be heard.