Skip to the content

Library/ 03 rolling-numberText

Rolling Number

Stats that count up like an odometer.

Scroll inside the preview

Settings

1200
60
once

What it does

Rolling Number is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called number ticker, counter, odometer or rolling stats.

Every digit spins up from zero on its own column, left to right, as the number scrolls into view. Currency signs, commas and percent stay put. Once settled it is plain text again, readable by search engines and screen readers.

Documentation

One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.

  1. Copy the prompt

  2. Ask for it

    For instance: Add this rolling number effect to the hero of my home page.
    What it sends
    Markdown
    # Foundry44 · Rolling Number
    
    Digits roll vertically like an odometer up to a value when scrolled into view.
    
    Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.
    
    ## Markup it expects
    
    The element marked `data-f44="rolling-number"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `rollingNumber()` or as props.
    
    ```html
    <span data-f44="rolling-number">12,480</span>
    ```
    
    ## Options
    
    | Option | Attribute | Type | Default | Description |
    |---|---|---|---|---|
    | `value` | `data-f44-value` | `string | number` | `` | The number to show. Defaults to the element's text. A string keeps your formatting ("$12,480"). |
    | `duration` | `data-f44-duration` | `number` | `1200` | Time in ms for one digit to roll. |
    | `stagger` | `data-f44-stagger` | `number` | `60` | Delay in ms between the start of two consecutive digits, left to right. |
    | `once` | `data-f44-once` | `boolean` | `true` | Roll only the first time the element enters the viewport. |
    
    ## Setup
    
    - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/rolling-number/rolling-number.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="rolling-number"` element and reads the `data-f44-*` attributes above.
    - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/rolling-number.json` writes `lib/rolling-number-core.ts` and `components/ui/rolling-number.tsx`, then use `<RollingNumber />` with the options as props.
    - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { RollingNumber } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { rollingNumber } from "@foundry44/core/rolling-number"`.
    - **React / Next.js by hand**: save the code below as `lib/rolling-number-core.ts` and import it from there (`@/lib/rolling-number-core` where that alias exists). Don't name it `rolling-number.ts` next to a `RollingNumber.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `rollingNumber(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
    - **Any other stack**: create on mount with `rollingNumber(element, options)`, `update(options)` when they change, `destroy()` on unmount.
    
    Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `rollingNumber(element)` on each element.
    
    ## Pitfalls
    
    - Respects prefers-reduced-motion: keep that behaviour.
    - In React, pass the number as `value`, not as children: the digits are rebuilt while rolling, which would conflict with React owning the text.
    - Pass a string to keep your formatting ("$12,480", "98.6%"). A number is shown as-is, without thousands separators.
    
    ## Code: `rolling-number-core.ts`
    
    ```ts
    // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
    /**
     * Foundry44 — rolling-number
     * Each digit rolls vertically like an odometer, from zero up to the number,
     * when it enters the viewport. Changing `value` rolls from the current number
     * to the new one. Symbols and separators ($ , . %) stay in place.
     *
     * At rest the element holds plain text: readable, selectable and indexable.
     * The digit columns only exist while rolling.
     *
     * Self-contained: no imports, so this file can be copied as-is.
     */
    
    export interface RollingNumberOptions {
      /** The number to show. Defaults to the element's text. A string keeps your formatting ("$12,480"). */
      value?: string | number;
      /** Time in ms for one digit to roll. @range 200 3000 50 */
      duration?: number;
      /** Delay in ms between the start of two consecutive digits, left to right. @range 0 200 5 */
      stagger?: number;
      /** Roll only the first time the element enters the viewport. */
      once?: boolean;
    }
    
    export interface RollingNumberInstance {
      update(options: RollingNumberOptions): void;
      destroy(): void;
    }
    
    export const rollingNumberDefaults: Required<RollingNumberOptions> = {
      value: "",
      duration: 1200,
      stagger: 60,
      once: true,
    };
    
    // Strong ease-out: the digits spin fast, then settle softly on their value.
    const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
    const DIGITS = "0123456789";
    const isDigit = (char: string) => char >= "0" && char <= "9";
    
    interface DigitSlot {
      placeholder: HTMLElement;
      column: HTMLElement;
    }
    
    export function rollingNumber(el: HTMLElement, options: RollingNumberOptions = {}): RollingNumberInstance {
      let opts = { ...rollingNumberDefaults, ...options };
    
      const noop: RollingNumberInstance = { update() {}, destroy() {} };
      if (typeof window === "undefined") return noop;
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
    
      // The text the element shows at rest, or will show once the roll ends.
      let target = opts.value === "" ? (el.textContent ?? "") : String(opts.value);
      let slots: DigitSlot[] = [];
      // Digits replaced by zeros: two numbers with the same shape can retarget the running columns.
      let rollingShape = "";
      let settleTimer = 0;
      let played = false;
      let observer: IntersectionObserver | null = null;
      if (el.textContent !== target) el.textContent = target;
    
      const shapeOf = (text: string) => text.replace(/[0-9]/g, "0");
    
      const settle = () => {
        clearTimeout(settleTimer);
        settleTimer = 0;
        slots = [];
        rollingShape = "";
        el.textContent = target;
      };
    
      // Each column holds 0–9 stacked; it is 10 lines tall, so one digit is 10% of its height.
      const moveColumns = (text: string, animate: boolean) => {
        const digits = Array.from(text).filter(isDigit);
        slots.forEach(({ placeholder, column }, index) => {
          const digit = Number(digits[index] ?? 0);
          placeholder.textContent = String(digit);
          column.style.transition = animate ? `transform ${opts.duration}ms ${EASE_OUT} ${index * opts.stagger}ms` : "none";
          column.style.transform = `translateY(${-digit * 10}%)`;
        });
        clearTimeout(settleTimer);
        settleTimer = window.setTimeout(settle, opts.duration + Math.max(0, slots.length - 1) * opts.stagger);
      };
    
      const buildColumns = (text: string) => {
        // Screen readers get the final number once; the moving digits are hidden from them.
        const announced = document.createElement("span");
        announced.textContent = text;
        announced.style.cssText =
          "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
    
        const visual = document.createElement("span");
        visual.setAttribute("aria-hidden", "true");
        visual.style.whiteSpace = "nowrap";
    
        slots = [];
        for (const char of Array.from(text)) {
          if (!isDigit(char)) {
            visual.append(char);
            continue;
          }
          // An invisible copy of the final digit gives the slot its width, height and baseline,
          // so the number sits exactly where the plain text will be once the roll ends.
          const slot = document.createElement("span");
          slot.style.cssText = "display:inline-block;position:relative;clip-path:inset(0 -0.2em)";
          const placeholder = document.createElement("span");
          placeholder.style.visibility = "hidden";
          const column = document.createElement("span");
          column.style.cssText =
            "position:absolute;top:0;left:0;right:0;display:flex;flex-direction:column;text-align:center";
          for (const digit of DIGITS) {
            const cell = document.createElement("span");
            cell.textContent = digit;
            column.append(cell);
          }
          slot.append(placeholder, column);
          visual.append(slot);
          slots.push({ placeholder, column });
        }
        el.replaceChildren(announced, visual);
        rollingShape = shapeOf(text);
      };
    
      const roll = (from: string, to: string) => {
        target = to;
        if (reduced || opts.duration <= 0) {
          settle();
          return;
        }
        if (slots.length && shapeOf(to) === rollingShape) {
          // Same shape as the running roll: CSS transitions retarget from where each digit is now.
          moveColumns(to, true);
          return;
        }
        buildColumns(to);
        // Align the starting digits on the right, so 980 → 1,240 rolls units into units.
        const fromDigits = Array.from(from).filter(isDigit);
        const startDigits = slots.map((_, index) => fromDigits[fromDigits.length - slots.length + index] ?? "0");
        moveColumns(startDigits.join(""), false);
        // Commit the starting position before transitioning away from it.
        el.getBoundingClientRect();
        moveColumns(to, true);
      };
    
      const play = () => {
        played = true;
        roll(shapeOf(target), target);
      };
    
      const bind = () => {
        if (opts.once && played) return;
        if (typeof IntersectionObserver === "undefined") {
          play();
          return;
        }
        observer = new IntersectionObserver(
          (entries) => {
            if (!entries.some((entry) => entry.isIntersecting)) return;
            play();
            if (opts.once) unbind();
          },
          // Wait until the number is a little inside the viewport so the roll is actually seen.
          { rootMargin: "0px 0px -10% 0px" },
        );
        observer.observe(el);
      };
    
      const unbind = () => {
        observer?.disconnect();
        observer = null;
      };
    
      // With reduced motion the number is simply shown; only `value` changes matter.
      if (!reduced) bind();
    
      return {
        update(next) {
          const previous = opts;
          opts = { ...opts, ...next };
          if (opts.value !== previous.value && opts.value !== "") {
            const value = String(opts.value);
            if (played) roll(target, value);
            else {
              // Not seen yet: show the value now, it rolls from zero when it scrolls into view.
              target = value;
              settle();
            }
          }
          if (!reduced && opts.once !== previous.once) {
            unbind();
            bind();
          }
        },
        destroy() {
          unbind();
          if (slots.length) settle();
        },
      };
    }
    ```