FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Number Ticker

Number Ticker

pnpm dlx shadcn@latest add …/r/number-ticker.json

An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.

$1,248.00
import { NumberTicker } from "@/components/ui/number-ticker"

export function Users() {
  return <NumberTicker value={12480} className="text-4xl font-semibold" />
}
flow.tsx
"use client";

import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { MinusIcon, PlusIcon, ShuffleIcon } from "lucide-react";
import { useId, useState } from "react";

/** Values of different lengths, so Shuffle shows places coming and going and the width gliding */
const VALUES = [1248, 98.5, 24501.75, 7, 312480, 3.2];
const formats = {
  currency: { style: "currency", currency: "USD" },
  plain: { maximumFractionDigits: 2 },
} as const satisfies Record<string, Intl.NumberFormatOptions>;
type Format = keyof typeof formats;

/*
 * One big number that changes, the way a price or a balance does: step it down or up, or shuffle to a value of
 * another length. Each change flows from what's on screen: the digits roll, the characters that stay glide to
 * their new places, new ones come in through the blur and the ones that go leave through it. Format in the
 * top-right corner. An empty row at the bottom, as tall as the setting's row, keeps the number centred.
 */
export default function NumberTickerFlow() {
  const id = useId();
  const [index, setIndex] = useState(0);
  const [value, setValue] = useState(VALUES[0] ?? 0);
  const [format, setFormat] = useState<Format>("currency");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-format`} className="text-sm text-muted-foreground">
          Format
        </label>
        <Select value={format} onValueChange={(next) => setFormat(next as Format)}>
          <SelectTrigger id={`${id}-format`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="currency">Currency</SelectItem>
            <SelectItem value="plain">Plain</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-col items-center justify-center gap-6">
        <NumberTicker
          value={value}
          from={VALUES[0]}
          format={formats[format]}
          locale="en-US"
          className="text-5xl font-semibold tracking-tight sm:text-6xl"
        />
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="icon"
            aria-label="Decrease by 100"
            onClick={() => setValue((current) => Math.max(0, Math.round((current - 100) * 100) / 100))}
          >
            <MinusIcon aria-hidden />
          </Button>
          <Button
            variant="outline"
            onClick={() => {
              const next = (index + 1) % VALUES.length;
              setIndex(next);
              setValue(VALUES[next] ?? 0);
            }}
          >
            <ShuffleIcon aria-hidden />
            Shuffle
          </Button>
          <Button
            variant="outline"
            size="icon"
            aria-label="Increase by 100"
            onClick={() => setValue((current) => Math.round((current + 100) * 100) / 100)}
          >
            <PlusIcon aria-hidden />
          </Button>
        </div>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/number-ticker.tsx
    "use client";
    
    import { flow as timing } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { useReducedMotion } from "motion/react";
    import {
      type ComponentProps,
      type CSSProperties,
      useCallback,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    
    /*
     * Number Ticker: a number that flows to its value (owner's roadmap, 2026-10-16), for prices, stats and counters.
     * Light DOM, no dependencies (licence notice below).
     * - Each digit is a column of 0–9 placed by CSS: one number, the digit it shows, decides every row's offset
     *   through mod(), so only its neighbours are ever in view. A spin animates that one number (a registered custom
     *   property) from the old digit to the new, on a spring sampled into linear() (`flow` in our motion presets).
     * - The number fades at its edges, 0.25em above and below (digits spin out of sight) and 0.5em at the sides
     *   (characters come, go and glide), through our shipped `--size-roll-fade` tokens.
     * - When it changes length, the integer part holds to its right and the fraction to its left: each character
     *   that stays glides to its new place, and the number's width glides as a counter-scaled transform, so text
     *   beside it moves smoothly. New characters fade in, a new digit spinning up from 0; characters that go stay
     *   where they were and fade out, a leaving digit spinning down to 0. Changed signs cross-fade.
     * - Continuous (on by default): when a higher place changes, the unchanged places below
     *   it spin a whole turn, so 9 → 2,481 reads as counting up.
     * - It rolls from `from` once it's half in view (once), or on mount with startOnView={false). The server sends
     *   the final value, so it's indexed and hydrates without a mismatch.
     * - Screen readers get the formatted value: the number is an image named by it, so the moving parts are never read.
     * - With reduced motion, or a browser without CSS mod() and linear(), it changes without moving.
     */
    
    /*
     * Portions of this file are adapted from number-flow, © 2024 Maxwell Barvian, under the MIT licence:
     *
     * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
     * documentation files (the "Software"), to deal in the Software without restriction, including without limitation
     * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
     * to permit persons to whom the Software is furnished to do so, subject to the following conditions:
     *
     * The above copyright notice and this permission notice shall be included in all copies or substantial portions of
     * the Software.
     *
     * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
     * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
     * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
     * IN THE SOFTWARE.
     */
    
    /** A part of the formatted number */
    type Part = { type: string; value: number | string; key: string; pos?: number };
    type Data = { pre: Part[]; integer: Part[]; fraction: Part[]; post: Part[]; label: string; value: number };
    
    /** How much of it shows before it rolls, so the roll isn't over before it's seen */
    const IN_VIEW = 0.5;
    /** The digits a place shows */
    const LENGTH = 10;
    /** The registered custom properties the animations run on */
    const D_OPACITY = "--_ticker-d-opacity";
    const D_WIDTH = "--_ticker-d-width";
    const DX = "--_ticker-dx";
    const D = "--_ticker-d";
    
    /** Half the fade above and below, on a whole pixel, pads each row; the fade itself is twice that */
    const PAD = "round(nearest, calc(var(--size-roll-fade) / 2), 1px)";
    const FADE_Y = `calc(${PAD} * 2)`;
    const FADE_X = "var(--size-roll-fade-x)";
    const FADE_X_SCALED = `calc(${FADE_X} / var(--scale-x))`; // check-tokens-ignore: the flow's own working variable, local to its stylesheet
    const CORNER = "#000 0, transparent 71%"; // check-tokens-ignore: a mask works on alpha; #000 is opacity, never painted
    
    /** The flow's stylesheet, with our tokens. One copy for the page: React hoists and dedupes it. */
    const CSS_TEXT = `[data-slot=number-ticker]{display:inline-block;direction:ltr;white-space:nowrap;isolation:isolate;line-height:1}
    .ticker-number,.ticker-number-inner{display:inline-block;transform-origin:left top}
    .ticker-number{--scale-x:calc(1 + var(${D_WIDTH}) / var(--width));transform:translateX(var(${DX})) scaleX(var(--scale-x));margin:0 calc(-1 * ${FADE_X});position:relative;-webkit-mask-image:linear-gradient(to right,transparent 0,#000 ${FADE_X_SCALED},#000 calc(100% - ${FADE_X_SCALED}),transparent),linear-gradient(to bottom,transparent 0,#000 ${FADE_Y},#000 calc(100% - ${FADE_Y}),transparent 100%),radial-gradient(at bottom right,${CORNER}),radial-gradient(at bottom left,${CORNER}),radial-gradient(at top left,${CORNER}),radial-gradient(at top right,${CORNER});-webkit-mask-size:100% calc(100% - ${FADE_Y} * 2),calc(100% - ${FADE_X_SCALED} * 2) 100%,${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y};-webkit-mask-position:center,center,top left,top right,bottom right,bottom left;-webkit-mask-repeat:no-repeat;mask-image:linear-gradient(to right,transparent 0,#000 ${FADE_X_SCALED},#000 calc(100% - ${FADE_X_SCALED}),transparent),linear-gradient(to bottom,transparent 0,#000 ${FADE_Y},#000 calc(100% - ${FADE_Y}),transparent 100%),radial-gradient(at bottom right,${CORNER}),radial-gradient(at bottom left,${CORNER}),radial-gradient(at top left,${CORNER}),radial-gradient(at top right,${CORNER});mask-size:100% calc(100% - ${FADE_Y} * 2),calc(100% - ${FADE_X_SCALED} * 2) 100%,${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y};mask-position:center,center,top left,top right,bottom right,bottom left;mask-repeat:no-repeat}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */
    .ticker-number-inner{padding:${PAD} ${FADE_X};transform:scaleX(calc(1 / var(--scale-x))) translateX(calc(-1 * var(${DX})))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */
    [data-slot=number-ticker] > :not(.ticker-number){z-index:5}
    .ticker-section,.ticker-symbol{display:inline-block;position:relative;isolation:isolate}
    .ticker-section::after{content:'\\200b';display:inline-block}
    .ticker-justify-left{transform-origin:center left}
    .ticker-justify-right{transform-origin:center right}
    .ticker-section > [inert],.ticker-symbol > [inert]{margin:0 !important;position:absolute !important;z-index:-1}
    .ticker-digit{display:inline-block;position:relative;--c:var(--current) + var(${D})}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */
    .ticker-digit-num,.ticker-number .ticker-section::after{padding:${PAD} 0}
    .ticker-digit-num{display:inline-block;--offset-raw:mod(var(--length) + var(--n) - mod(var(--c),var(--length)),var(--length));--offset:calc(var(--offset-raw) - var(--length) * round(down,var(--offset-raw) / (var(--length) / 2),1));--y:clamp(-100%,var(--offset) * 100%,100%);transform:translateY(var(--y))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */
    .ticker-digit-num[inert]{position:absolute;top:0;left:50%;transform:translateX(-50%) translateY(var(--y))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */
    .ticker-digit:not(.is-spinning) .ticker-digit-num[inert]{display:none}
    .ticker-symbol-value{display:inline-block;mix-blend-mode:plus-lighter;white-space:pre}
    .ticker-justify-left .ticker-symbol > [inert]{left:0}
    .ticker-justify-right .ticker-symbol > [inert]{right:0}
    .ticker-presence{opacity:calc(1 + var(${D_OPACITY}))}
    .ticker-static{display:inline-block}
    .ticker-static > span{display:inline-block;padding:${PAD} 0;white-space:pre}`;
    
    /** Whether this browser can run the flow: CSS mod(), linear() easing and registered custom properties */
    let able: boolean | null = null;
    function canAnimate() {
      if (able !== null) return able;
      const modulo = typeof CSS !== "undefined" && !!CSS.supports?.("line-height", "mod(1,1)");
      let linear = false;
      try {
        document.createElement("div").animate({ opacity: 0 }, { easing: "linear(0, 1)" });
        linear = true;
      } catch {
        linear = false;
      }
      let registered = false;
      try {
        CSS.registerProperty({ name: D_OPACITY, syntax: "<number>", inherits: false, initialValue: "0" });
        CSS.registerProperty({ name: DX, syntax: "<length>", inherits: true, initialValue: "0px" });
        CSS.registerProperty({ name: D_WIDTH, syntax: "<number>", inherits: false, initialValue: "0" });
        CSS.registerProperty({ name: D, syntax: "<number>", inherits: true, initialValue: "0" });
        registered = true;
      } catch {
        // Already registered by another ticker on the page, or not supported
        registered = typeof CSS !== "undefined" && typeof CSS.registerProperty === "function";
      }
      able = modulo && linear && registered;
      return able;
    }
    
    /**
     * The formatted number in parts: what comes before it (a currency sign), the integer digits and
     * separators keyed from the right (so the ones stay the ones as it grows), the fraction keyed from the left, and
     * what comes after (a percent sign). Digits carry their place: 0 the ones, -1 the tenths.
     */
    function toData(value: number, formatter: Intl.NumberFormat, prefix?: string, suffix?: string): Data {
      const parts: { type: string; value: string }[] = formatter.formatToParts(value);
      if (prefix) parts.unshift({ type: "prefix", value: prefix });
      if (suffix) parts.push({ type: "suffix", value: suffix });
      const pre: Part[] = [];
      const integers: Part[] = [];
      const fraction: Part[] = [];
      const post: Part[] = [];
      const counts: Record<string, number> = {};
      const keyOf = (type: string) => {
        counts[type] = (counts[type] ?? -1) + 1;
        return `${type}:${counts[type]}`;
      };
      let label = "";
      let seenInteger = false;
      let seenDecimal = false;
      for (const part of parts) {
        label += part.value;
        const type = part.type === "minusSign" || part.type === "plusSign" ? "sign" : part.type;
        if (type === "integer") {
          seenInteger = true;
          for (const char of part.value) integers.push({ type, value: Number.parseInt(char, 10), key: "" });
        } else if (type === "group") integers.push({ type, value: part.value, key: "" });
        else if (type === "decimal") {
          seenDecimal = true;
          fraction.push({ type, value: part.value, key: keyOf(type) });
        } else if (type === "fraction") {
          for (const char of part.value) {
            const key = keyOf(type);
            fraction.push({ type, value: Number.parseInt(char, 10), key, pos: -1 - (counts[type] ?? 0) });
          }
        } else (seenInteger || seenDecimal ? post : pre).push({ type, value: part.value, key: keyOf(type) });
      }
      const integer: Part[] = [];
      for (let index = integers.length - 1; index >= 0; index--) {
        const part = integers[index];
        if (!part) continue;
        const key = keyOf(part.type);
        integer.unshift(part.type === "integer" ? { ...part, key, pos: counts[part.type] } : { ...part, key });
      }
      return { pre, integer, fraction, post, label, value };
    }
    
    /** The server's markup, and the first paint before the flow takes over: each character in a padded box */
    function staticHtml(data: Data) {
      const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;");
      const all = [...data.pre, ...data.integer, ...data.fraction, ...data.post];
      return `<span class="ticker-static">${all.map((part) => `<span>${escapeHtml(String(part.value))}</span>`).join("")}</span>`;
    }
    
    const span = (props: { className?: string; part?: string; textContent?: string }, children: Node[] = []) => {
      const element = document.createElement("span");
      if (props.className) element.className = props.className;
      if (props.textContent !== undefined) element.textContent = props.textContent;
      for (const child of children) element.appendChild(child);
      return element;
    };
    
    /** Where a popped character sits, from the edge its section holds to */
    const offsetFrom = (element: HTMLElement, edge: "left" | "right") =>
      edge === "left"
        ? element.offsetLeft
        : ((element.offsetParent instanceof HTMLElement ? element.offsetParent.offsetWidth : 0) ?? 0) -
          element.offsetWidth -
          element.offsetLeft;
    
    const accumulate = (effect: EffectTiming): KeyframeAnimationOptions => ({
      ...effect,
      composite: "accumulate",
    });
    
    type Options = { trend: number | ((before: number, after: number) => number); continuous: boolean };
    
    /** The flow on one host: a plain object driving our span */
    class Flow {
      host: HTMLElement;
      options: Options;
      data: Data | null = null;
      animated = true;
      reduce = false;
      computedAnimated = false;
      computedTrend = 0;
      /** Continuous: the highest place that changes, or nothing */
      changedUpTo: number | undefined;
      private wasReady = false;
      private finishing: AbortController | undefined;
      private pre!: SymbolSection;
      private num!: NumberPart;
      private post!: SymbolSection;
    
      constructor(host: HTMLElement, options: Options) {
        this.host = host;
        this.options = options;
      }
    
      private ready() {
        return (
          canAnimate() && this.animated && !this.reduce && this.host.ownerDocument.visibilityState !== "hidden"
        );
      }
    
      set(data: Data) {
        if (data === this.data) return;
        if (!this.data) {
          this.data = data;
          this.host.replaceChildren();
          this.pre = new SymbolSection(this, data.pre, "right");
          this.num = new NumberPart(this, data.integer, data.fraction);
          this.post = new SymbolSection(this, data.post, "left");
          this.host.append(this.pre.el, this.num.el, this.post.el);
          return;
        }
        const before = this.data;
        this.data = data;
        const { trend, continuous } = this.options;
        this.computedTrend = typeof trend === "function" ? trend(before.value, data.value) : trend;
        this.computedAnimated =
          this.ready() &&
          // Nothing to animate while it isn't laid out (in a hidden tab panel, say)
          this.host.offsetWidth > 0 &&
          this.host.offsetHeight > 0;
        this.changedUpTo = continuous && this.computedTrend ? highestChange(before, data) : undefined;
        this.willUpdate();
        this.pre.update(data.pre);
        this.num.update(data.integer, data.fraction);
        this.post.update(data.post);
        this.didUpdate();
      }
    
      /** Changing `animated` finishes whatever is running */
      setAnimated(animated: boolean) {
        if (animated === this.animated) return;
        this.animated = animated;
        for (const animation of this.animations()) animation.finish();
      }
    
      animations() {
        return this.host.getAnimations?.({ subtree: true }) ?? [];
      }
    
      private willUpdate() {
        this.wasReady = this.ready();
        if (!this.wasReady) return;
        this.pre.willUpdate();
        this.num.willUpdate();
        this.post.willUpdate();
      }
    
      private didUpdate() {
        if (!this.computedAnimated || !this.wasReady) return;
        if (this.finishing) this.finishing.abort();
        this.pre.didUpdate();
        this.num.didUpdate();
        this.post.didUpdate();
        const controller = new AbortController();
        Promise.all(this.animations().map((animation) => animation.finished)).then(
          () => {
            if (controller.signal.aborted) return;
            this.host.dispatchEvent(new Event("animationsfinish"));
            this.finishing = undefined;
          },
          () => {},
        );
        this.finishing = controller;
      }
    
      /** Stops everything, for unmounting */
      destroy() {
        for (const animation of this.animations()) animation.cancel();
      }
    }
    
    /** Continuous: the highest place where the digits differ, either way */
    function highestChange(before: Data, after: Data) {
      const digits = (data: Data) =>
        data.integer.concat(data.fraction).filter((part) => part.type === "integer" || part.type === "fraction");
      const old = digits(before);
      const next = digits(after);
      const gone = old.find(
        (part) => !next.find((other) => other.pos === part.pos && other.value === part.value),
      );
      const came = next.find(
        (part) => !old.find((other) => part.pos === other.pos && part.value === other.value),
      );
      const a = gone?.pos;
      const b = came?.pos;
      return a == null ? b : b == null ? a : Math.max(a, b);
    }
    
    /** The number itself: the integer and fraction sections, inside the faded, counter-scaled frame */
    class NumberPart {
      el: HTMLElement;
      private integer: IntegerSection;
      private fraction: IntegerSection;
      private prevWidth = 0;
      private prevLeft = 0;
    
      constructor(flow: Flow, integer: Part[], fraction: Part[]) {
        this.integer = new IntegerSection(flow, integer, "right");
        this.fraction = new IntegerSection(flow, fraction, "left");
        const inner = span({ className: "ticker-number-inner" }, [this.integer.el, this.fraction.el]);
        this.el = span({ className: "ticker-number" }, [inner]);
      }
    
      willUpdate() {
        this.prevWidth = this.el.offsetWidth;
        this.prevLeft = this.el.getBoundingClientRect().left;
        this.integer.willUpdate();
        this.fraction.willUpdate();
      }
    
      update(integer: Part[], fraction: Part[]) {
        this.integer.update(integer);
        this.fraction.update(fraction);
      }
    
      didUpdate() {
        const rect = this.el.getBoundingClientRect();
        this.integer.didUpdate();
        this.fraction.didUpdate();
        const dx = this.prevLeft - rect.left;
        const width = this.el.offsetWidth;
        const dw = this.prevWidth - width;
        this.el.style.setProperty("--width", String(width));
        this.el.animate({ [DX]: [`${dx}px`, "0px"], [D_WIDTH]: [dw, 0] }, accumulate(timing.transform));
      }
    }
    
    /** Something that fades in as it comes and out as it goes, then removes itself */
    class Presence {
      el: HTMLElement;
      protected flow: Flow;
      private shown = true;
      private onRemove: (() => void) | undefined;
      private remove = () => {
        this.el.remove();
        this.onRemove?.();
      };
    
      constructor(
        flow: Flow,
        el: HTMLElement,
        { onRemove, animateIn = false }: { onRemove?: () => void; animateIn?: boolean },
      ) {
        this.flow = flow;
        this.el = el;
        this.onRemove = onRemove;
        el.classList.add("ticker-presence");
        if (flow.computedAnimated && animateIn)
          el.animate({ [D_OPACITY]: [-0.9999, 0] }, accumulate(timing.opacity));
      }
    
      get present() {
        return this.shown;
      }
    
      set present(present: boolean) {
        if (this.shown === present) return;
        this.shown = present;
        if (present) this.el.removeAttribute("inert");
        else this.el.setAttribute("inert", "");
        if (!this.flow.computedAnimated) {
          if (!present) this.remove();
          return;
        }
        this.el.style.setProperty(D_OPACITY, present ? "0" : "-.999");
        this.el.animate({ [D_OPACITY]: present ? [-0.9999, 0] : [0.999, 0] }, accumulate(timing.opacity));
        if (present) this.flow.host.removeEventListener("animationsfinish", this.remove);
        else this.flow.host.addEventListener("animationsfinish", this.remove, { once: true });
      }
    }
    
    type Edge = "left" | "right";
    
    abstract class Char extends Presence {
      section: Section;
      value: number | string;
      constructor(
        section: Section,
        value: number | string,
        el: HTMLElement,
        options: { onRemove?: () => void; animateIn?: boolean },
      ) {
        super(section.flow, el, options);
        this.section = section;
        this.value = value;
      }
      abstract willUpdate(sectionRect: DOMRect): void;
      abstract update(value: number | string): void;
      abstract didUpdate(sectionRect: DOMRect): void;
    }
    
    /** One digit: a column of 0–9 whose rows CSS places from the digit it shows */
    class Digit extends Char {
      pos: number;
      private numbers: HTMLElement[];
      private prevValue = 0;
      private prevCenter = 0;
      private stopSpinning = () => this.el.classList.remove("is-spinning");
    
      constructor(
        section: Section,
        type: string,
        value: number,
        pos: number,
        options: { onRemove?: () => void; animateIn?: boolean },
      ) {
        const numbers = Array.from({ length: LENGTH }, (_, n) => {
          const number = span({ className: "ticker-digit-num", textContent: String(n) });
          if (n !== value) number.setAttribute("inert", "");
          number.style.setProperty("--n", String(n));
          return number;
        });
        const el = span({ className: `ticker-digit ticker-${type}-digit` }, numbers);
        el.style.setProperty("--current", String(value));
        el.style.setProperty("--length", String(LENGTH));
        super(section, value, el, options);
        this.pos = pos;
        this.numbers = numbers;
      }
    
      private centre(rect: DOMRect, sectionRect: DOMRect) {
        const edge = this.section.justify;
        const offset = rect[edge] - sectionRect[edge];
        const half = rect.width / 2;
        return edge === "left" ? offset + half : offset - half;
      }
    
      willUpdate(sectionRect: DOMRect) {
        this.prevValue = Number(this.value);
        this.prevCenter = this.centre(this.el.getBoundingClientRect(), sectionRect);
      }
    
      update(value: number | string) {
        const digit = Number(value);
        this.el.style.setProperty("--current", String(digit));
        this.numbers.forEach((number, n) => {
          if (n === digit) number.removeAttribute("inert");
          else number.setAttribute("inert", "");
        });
        this.value = digit;
      }
    
      didUpdate(sectionRect: DOMRect) {
        const shift = this.prevCenter - this.centre(this.el.getBoundingClientRect(), sectionRect);
        if (shift)
          this.el.animate({ transform: [`translateX(${shift}px)`, "none"] }, accumulate(timing.transform));
        const delta = this.delta();
        if (!delta) return;
        this.el.classList.add("is-spinning");
        this.el.animate({ [D]: [-delta, 0] }, accumulate(timing.transform));
        this.flow.host.addEventListener("animationsfinish", this.stopSpinning, { once: true });
      }
    
      /** How far it spins: the way the trend says, round the column if it has to; a whole turn when continuous */
      private delta() {
        const value = Number(this.value);
        const diff = value - this.prevValue;
        const { changedUpTo, computedTrend } = this.flow;
        if (!diff && changedUpTo != null && changedUpTo >= this.pos) return LENGTH * computedTrend;
        const trend = computedTrend || Math.sign(diff);
        if (trend < 0 && value > this.prevValue) return value - LENGTH - this.prevValue;
        if (trend > 0 && value < this.prevValue) return LENGTH - this.prevValue + value;
        return diff;
      }
    }
    
    /** A separator, sign or symbol: a changed one cross-fades */
    class Symbol_ extends Char {
      type: string;
      private values = new Map<string, Presence>();
      private prevOffset = 0;
    
      constructor(
        section: Section,
        type: string,
        value: string,
        options: { onRemove?: () => void; animateIn?: boolean },
      ) {
        const inner = span({ className: "ticker-symbol-value", textContent: value });
        super(section, value, span({ className: `ticker-symbol ticker-${type}` }, [inner]), options);
        this.type = type;
        this.values.set(value, new Presence(this.flow, inner, { onRemove: () => this.values.delete(value) }));
      }
    
      willUpdate(sectionRect: DOMRect) {
        if (this.type === "decimal") return;
        const edge = this.section.justify;
        this.prevOffset = this.el.getBoundingClientRect()[edge] - sectionRect[edge];
      }
    
      update(value: number | string) {
        const text = String(value);
        if (this.value !== text) {
          const old = this.values.get(String(this.value));
          if (old) old.present = false;
          const kept = this.values.get(text);
          if (kept) kept.present = true;
          else {
            const inner = span({ className: "ticker-symbol-value", textContent: text });
            this.el.appendChild(inner);
            this.values.set(
              text,
              new Presence(this.flow, inner, { animateIn: true, onRemove: () => this.values.delete(text) }),
            );
          }
        }
        this.value = text;
      }
    
      didUpdate(sectionRect: DOMRect) {
        if (this.type === "decimal") return;
        const edge = this.section.justify;
        const shift = this.prevOffset - (this.el.getBoundingClientRect()[edge] - sectionRect[edge]);
        if (shift)
          this.el.animate({ transform: [`translateX(${shift}px)`, "none"] }, accumulate(timing.transform));
      }
    }
    
    /** A run of characters held to one edge: characters that go are popped out where they were, then fade */
    abstract class Section {
      flow: Flow;
      el: HTMLElement;
      justify: Edge;
      protected children = new Map<string, Char>();
      private prevOffset = 0;
    
      constructor(flow: Flow, parts: Part[], justify: Edge) {
        this.flow = flow;
        this.justify = justify;
        const chars = parts.map((part) => this.add(part).el);
        this.el = span({ className: `ticker-section ticker-justify-${justify}` }, chars);
      }
    
      protected add(part: Part, { startAtZero = false, animateIn = false } = {}): Char {
        const onRemove = () => this.children.delete(part.key);
        const char =
          part.type === "integer" || part.type === "fraction"
            ? new Digit(this, part.type, startAtZero ? 0 : Number(part.value), part.pos ?? 0, {
                onRemove,
                animateIn,
              })
            : new Symbol_(this, part.type, String(part.value), { onRemove, animateIn });
        this.children.set(part.key, char);
        return char;
      }
    
      protected unpop(char: Char) {
        char.el.removeAttribute("inert");
        char.el.style.top = "";
        char.el.style[this.justify] = "";
      }
    
      protected pop(chars: Map<string, Char>) {
        for (const char of chars.values()) {
          char.el.style.top = `${char.el.offsetTop}px`;
          char.el.style[this.justify] = `${offsetFrom(char.el, this.justify)}px`;
        }
        for (const char of chars.values()) {
          char.el.setAttribute("inert", "");
          char.present = false;
        }
      }
    
      protected addNewAndUpdateExisting(parts: Part[]) {
        const added = new Map<Part, Char>();
        const kept = new Map<Part, Char>();
        const left = this.justify === "left";
        const ordered = left ? [...parts].reverse() : parts;
        for (const part of ordered) {
          let char = this.children.get(part.key);
          if (char) {
            kept.set(part, char);
            this.unpop(char);
            char.present = true;
          } else {
            char = this.add(part, { startAtZero: true, animateIn: true });
            added.set(part, char);
          }
          if (left) this.el.prepend(char.el);
          else this.el.append(char.el);
        }
        if (this.flow.computedAnimated) {
          const rect = this.el.getBoundingClientRect();
          for (const char of added.values()) char.willUpdate(rect);
        }
        for (const [part, char] of added) char.update(part.value);
        for (const [part, char] of kept) char.update(part.value);
      }
    
      willUpdate() {
        const rect = this.el.getBoundingClientRect();
        this.prevOffset = rect[this.justify];
        for (const char of this.children.values()) char.willUpdate(rect);
      }
    
      didUpdate() {
        const rect = this.el.getBoundingClientRect();
        for (const char of this.children.values()) char.didUpdate(rect);
        const shift = this.prevOffset - rect[this.justify];
        if (shift && this.children.size)
          this.el.animate({ transform: [`translateX(${shift}px)`, "none"] }, accumulate(timing.transform));
      }
    
      abstract update(parts: Part[]): void;
    }
    
    /** The integer or fraction digits: a digit that goes spins down to 0 as it fades */
    class IntegerSection extends Section {
      update(parts: Part[]) {
        const removed = new Map<string, Char>();
        for (const [key, char] of this.children) {
          if (!parts.find((part) => part.key === key)) removed.set(key, char);
          this.unpop(char);
        }
        this.addNewAndUpdateExisting(parts);
        for (const char of removed.values()) if (char instanceof Digit) char.update(0);
        this.pop(removed);
      }
    }
    
    /** What comes before or after the number */
    class SymbolSection extends Section {
      update(parts: Part[]) {
        const removed = new Map<string, Char>();
        for (const [key, char] of this.children)
          if (!parts.find((part) => part.key === key)) removed.set(key, char);
        this.pop(removed);
        this.addNewAndUpdateExisting(parts);
      }
    }
    
    export type NumberTickerProps = Omit<ComponentProps<"span">, "children" | "prefix"> & {
      /** The number to show */
      value: number;
      /** Where the first roll starts */
      from?: number;
      /** Intl.NumberFormat options, for example { style: "currency", currency: "USD" } */
      format?: Intl.NumberFormatOptions;
      /** The locale to format in. en-US unless you pass one, so the server and the browser agree */
      locale?: string;
      /** Text before the number */
      prefix?: string;
      /** Text after the number */
      suffix?: string;
      /** Which way the digits turn. Up when the number grows and down when it shrinks, unless you set it */
      direction?: "up" | "down";
      /** When a higher place changes, the places below spin a whole turn, so it reads as counting */
      continuous?: boolean;
      /** Seconds to wait before the first roll */
      delay?: number;
      /** Roll when it scrolls into view (once). With false, it rolls on mount */
      startOnView?: boolean;
    };
    
    /**
     * A number that flows to its value, digit by digit. It inherits the text's size and colour; set them with
     * `className`. Its line height is 1, so give it a size rather than relying on the line around it.
     */
    function NumberTicker({
      value,
      from = 0,
      format,
      locale = "en-US",
      prefix,
      suffix,
      direction,
      continuous = true,
      delay = 0,
      startOnView = true,
      className,
      style,
      ref,
      ...props
    }: NumberTickerProps) {
      // Format options compared by content, so an inline object doesn't make a new formatter each render
      const formatKey = JSON.stringify(format ?? {});
      // biome-ignore lint/correctness/useExhaustiveDependencies: format is compared through formatKey
      const formatter = useMemo(() => new Intl.NumberFormat(locale, format), [locale, formatKey]);
      const data = useMemo(() => toData(value, formatter, prefix, suffix), [value, formatter, prefix, suffix]);
      const reduce = useReducedMotion() ?? false;
      const host = useRef<HTMLSpanElement | null>(null);
      const flow = useRef<Flow | null>(null);
      // Whether the first roll has happened; until then a new value waits for it
      const started = useRef(false);
      // The server's markup and the first paint; fixed, so React never touches what the flow builds inside
      const [initial] = useState(() => ({ __html: staticHtml(data) }));
      const latest = useRef({ data, from, formatter, prefix, suffix, delay, startOnView, reduce });
      latest.current = { data, from, formatter, prefix, suffix, delay, startOnView, reduce };
      const options: Options = {
        trend: direction === "up" ? 1 : direction === "down" ? -1 : (before, after) => Math.sign(after - before),
        continuous,
      };
      if (flow.current) flow.current.options = options;
    
      const setRef = useCallback(
        (node: HTMLSpanElement | null) => {
          host.current = node;
          if (typeof ref === "function") ref(node);
          else if (ref) ref.current = node;
        },
        [ref],
      );
    
      // The flow takes over the server's markup before the first paint: at `from`, then the value once it's seen
      // biome-ignore lint/correctness/useExhaustiveDependencies: built once; later values go through the effect below
      useLayoutEffect(() => {
        const node = host.current;
        if (!node) return;
        const settings = latest.current;
        const engine = new Flow(node, options);
        flow.current = engine;
        engine.reduce = settings.reduce;
        const rolls = !settings.reduce && canAnimate();
        engine.set(
          rolls ? toData(settings.from, settings.formatter, settings.prefix, settings.suffix) : settings.data,
        );
        let timer: ReturnType<typeof setTimeout> | undefined;
        const go = () => {
          timer = setTimeout(() => {
            started.current = true;
            engine.set(latest.current.data);
          }, settings.delay * 1000);
        };
        let observer: IntersectionObserver | undefined;
        if (!rolls) started.current = true;
        else if (!settings.startOnView || typeof IntersectionObserver === "undefined") go();
        else {
          observer = new IntersectionObserver(
            (entries) => {
              if (!entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= IN_VIEW)) return;
              observer?.disconnect();
              go();
            },
            { threshold: IN_VIEW },
          );
          observer.observe(node);
        }
        return () => {
          observer?.disconnect();
          clearTimeout(timer);
          engine.destroy();
          flow.current = null;
          started.current = false;
        };
      }, []);
    
      // Later values flow from what's on screen
      useLayoutEffect(() => {
        const engine = flow.current;
        if (!engine || !started.current) return;
        engine.reduce = reduce;
        engine.set(data);
      }, [data, reduce]);
    
      return (
        <>
          {/* One stylesheet for the page: React hoists it to the head and keeps one copy however many tickers */}
          <style href="fewtone-number-ticker" precedence="medium">
            {CSS_TEXT}
          </style>
          <span
            ref={setRef}
            data-slot="number-ticker"
            // The number is a picture named by its value, so screen readers read it once and never the roll
            role="img"
            aria-label={data.label}
            className={cn("tabular-nums", className)}
            style={style as CSSProperties}
            {...props}
            // biome-ignore lint/security/noDangerouslySetInnerHtml: our own escaped digits, the server's markup; the flow builds inside it after
            dangerouslySetInnerHTML={initial}
          />
        </>
      );
    }
    
    export { NumberTicker };
  5. 5

    Update the import paths to match your project.

Examples

Stats on view

Each stat rolls up from 0 once it's half in view. Places 0 doesn't have, and the separators and signs beside them, come in as they roll, so the number grows leftwards. Press Replay to roll again.

  • 12,480Users
  • $84,320Revenue
  • 99.98%Uptime
stats.tsx
import { NumberTicker } from "@/components/ui/number-ticker";

const stats: { label: string; value: number; format?: Intl.NumberFormatOptions }[] = [
  { label: "Users", value: 12480 },
  {
    label: "Revenue",
    value: 84320,
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 },
  },
  { label: "Uptime", value: 0.9998, format: { style: "percent", minimumFractionDigits: 2 } },
];

/*
 * A count, a currency and a percentage rolling to their values, named underneath, large enough to see the
 * reels spin and lock in from the left. Each starts from 0, so the places 0 doesn't have fade in as they roll.
 * Replay rolls them again.
 */
export default function NumberTickerStats() {
  return (
    <ul className="flex flex-wrap justify-center gap-x-12 gap-y-8">
      {stats.map((stat) => (
        <li key={stat.label} className="flex flex-col items-center gap-1">
          <NumberTicker
            value={stat.value}
            format={stat.format}
            locale="en-US"
            className="text-5xl font-semibold tracking-tight"
          />
          <span className="text-xs text-muted-foreground">{stat.label}</span>
        </li>
      ))}
    </ul>
  );
}

API reference

A span that inherits the text's size and colour, with a line height of 1. It's an image named by its formatted value, so screen readers hear the number, never the roll. Also accepts every native span prop.

PropTypeDefaultDescription
valuenumber–The number to show.
from?number0Where the first roll starts.
format?Intl.NumberFormatOptions–How to write it, for example { style: "currency", currency: "USD" } or { style: "percent" }.
locale?string"en-US"The locale to format in. Fixed by default so the server and the browser write the same text.
prefix?string–Text before the number, part of what flows.
suffix?string–Text after the number, part of what flows.
continuous?booleantrueWhen a higher place changes, the places below spin a whole turn, so a jump reads as counting. Off, each digit takes the short way.
direction?"up" | "down"–Which way the digits turn. Left out, up when the number grows and down when it shrinks; set it to always turn one way, like an odometer.
delay?number0Seconds to wait before the first roll.
startOnView?booleantrueRoll once it's half in view, once. With false, it rolls as soon as it mounts.

Related components

  • 64%
    Progress

    An accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.

  • 3
    Number Input

    An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.

  • Generating a reply…Generating a reply…
    Text Effects

    Accessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.