Rolling Number
Stats that count up like an odometer.
Settings
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.
Copy the prompt
Ask for it
For instance: Add this rolling number effect to the hero of my home page.What it sends# 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(); }, }; } ```
Two files that become yours: the engine, written without React, and a thin component around it. No package to install. The component is marked use client, so it sits inside a server component as it is.
Add it with the shadcn CLI
In a project set up for shadcn/ui, one command writes both files. Without it, copy them by hand in the two steps below.npx shadcn@latest add https://foundry44.dev/r/rolling-number.json
Or add the engine by hand
Save it aslib/rolling-number-core.ts. It has no dependencies and does nothing on the server.And the component
Save it ascomponents/ui/rolling-number.tsx. It creates the effect on mount, passes every prop change to it live, and cleans up on unmount.// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) // Generated by scripts/generate.mjs. Do not edit: change the core component instead. "use client"; import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; import { rollingNumber, type RollingNumberOptions, type RollingNumberInstance } from "@/lib/rolling-number-core"; export interface RollingNumberProps extends RollingNumberOptions, Omit<ComponentPropsWithoutRef<"span">, keyof RollingNumberOptions> {} export function RollingNumber({ value, duration, stagger, once, style, ...props }: RollingNumberProps) { const ref = useRef<HTMLElementTagNameMap["span"]>(null); const instance = useRef<RollingNumberInstance | null>(null); useEffect(() => { if (!ref.current) return; instance.current = rollingNumber(ref.current); return () => { instance.current?.destroy(); instance.current = null; }; }, []); // Runs right after creation, then on every option change. useEffect(() => { instance.current?.update(definedOnly({ value, duration, stagger, once })); }, [value, duration, stagger, once]); return <span ref={ref} style={style} {...props} />; } function definedOnly<T extends object>(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; }
Or install the package
It is also on npm, in@foundry44/reactwith the other free components. Import it from there instead of@/components/ui.npm install @foundry44/react
import { RollingNumber } from "@foundry44/react";
Use it
Every prop is optional: with none, you get the effect as the preview shows it.import { RollingNumber } from "@/components/ui/rolling-number"; export function Example() { return ( <RollingNumber value="" duration={1200} stagger={60} once={true} > {/* your content */} </RollingNumber> ); }
PropsProp Default What it is value The number to show. Defaults to the element's text. A string keeps your formatting ("$12,480"). duration 1200 Time in ms for one digit to roll. stagger 60 Delay in ms between the start of two consecutive digits, left to right. once true Roll only the first time the element enters the viewport. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - 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.
- It follows
One file, readable, no dependencies and no build step. It sets itself up on every element marked data-f44="rolling-number", in plain HTML, WordPress, Framer embeds or any framework.
Load the script
Download it, put it next to your page and load it once, anywhere in the page.<script src="rolling-number.js" defer></script>
The file/*! * Foundry44 · Rolling Number — https://foundry44.dev/components/rolling-number * Digits roll vertically like an odometer up to a value when scrolled into view. * Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) * * Usage: load this file, then add data-f44="rolling-number" to any element. * Optional attributes (defaults shown): * data-f44-value="" * data-f44-duration="1200" * data-f44-stagger="60" * data-f44-once="true" * Manual: const instance = Foundry44.rollingNumber(element, { value: "", duration: 1200, stagger: 60, once: true }); * instance.destroy(); * Re-scan the page after adding elements dynamically: Foundry44.init(); * * 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. */ (function () { "use strict"; /** * 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. */ const rollingNumberDefaults = { 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) => char >= "0" && char <= "9"; function rollingNumber(el, options = {}) { var _a, _b, _c; let opts = { ...rollingNumberDefaults, ...options }; const noop = { update() {}, destroy() {} }; if (typeof window === "undefined") return noop; const reduced = (_b = (_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, "(prefers-reduced-motion: reduce)").matches) !== null && _b !== void 0 ? _b : false; // The text the element shows at rest, or will show once the roll ends. let target = opts.value === "" ? ((_c = el.textContent) !== null && _c !== void 0 ? _c : "") : String(opts.value); let slots = []; // 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 = null; if (el.textContent !== target) el.textContent = target; const shapeOf = (text) => 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, animate) => { const digits = Array.from(text).filter(isDigit); slots.forEach(({ placeholder, column }, index) => { var _a; const digit = Number((_a = digits[index]) !== null && _a !== void 0 ? _a : 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) => { // 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, to) => { 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) => { var _a; return (_a = fromDigits[fromDigits.length - slots.length + index]) !== null && _a !== void 0 ? _a : "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 === null || observer === void 0 ? void 0 : 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(); }, }; } function parse(value) { if (value === "true") return true; if (value === "false") return false; var n = Number(value); return value !== "" && !isNaN(n) ? n : value; } function init() { document.querySelectorAll('[data-f44="rolling-number"]').forEach(function (el) { if (el.__f44) return; var options = {}; [ ["value", "data-f44-value"], ["duration", "data-f44-duration"], ["stagger", "data-f44-stagger"], ["once", "data-f44-once"], ].forEach(function (pair) { var value = el.getAttribute(pair[1]); if (value !== null) options[pair[0]] = parse(value); }); el.__f44 = rollingNumber(el, options); }); } var Foundry44 = (window.Foundry44 = window.Foundry44 || {}); Foundry44.rollingNumber = rollingNumber; Foundry44._inits = Foundry44._inits || []; Foundry44._inits.push(init); Foundry44.init = function () { Foundry44._inits.forEach(function (fn) { fn(); }); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();
Mark your element
Adddata-f44="rolling-number". Each setting is an attribute on the same element, and an attribute always wins over the default.<div data-f44="rolling-number" data-f44-value=""> <!-- your content --> </div>
SettingsAttribute Default What it is data-f44-value The number to show. Defaults to the element's text. A string keeps your formatting ("$12,480"). data-f44-duration 1200 Time in ms for one digit to roll. data-f44-stagger 60 Delay in ms between the start of two consecutive digits, left to right. data-f44-once true Roll only the first time the element enters the viewport. Drive it from JavaScript
When the element arrives after the page has loaded, or when the settings come from your code.// an element without data-f44, or the script would set it up twice const element = document.querySelector(".my-element"); const instance = Foundry44.rollingNumber(element, { value: "", duration: 1200 }); instance.update({ value: "" }); // any option, live instance.destroy(); // listeners and observers gone, inline styles restored // elements added after the page loaded Foundry44.init();
Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - 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.
- It follows
For a site builder: Webflow, WordPress, Shopify, Framer, or any site that accepts HTML. One block carries the whole script; paste it, then mark an element.
Paste the embed
Paste it where your builder takes custom HTML: an embed or code block on the page, or the site's footer code to use the effect on every page.Mark the element
Give the element the attributedata-f44with the valuerolling-number. Most builders have a field for custom attributes; otherwise, add it to the element's HTML.SettingsEvery setting is an attribute too, on the same element. Leave one out and the default applies.Attribute Default What it is data-f44-value The number to show. Defaults to the element's text. A string keeps your formatting ("$12,480"). data-f44-duration 1200 Time in ms for one digit to roll. data-f44-stagger 60 Delay in ms between the start of two consecutive digits, left to right. data-f44-once true Roll only the first time the element enters the viewport. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - 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.
- It follows