← Components

Odometer

Numbers that roll digit by digit, like an odometer.

Text · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

1.6 s
0.060 s
0
Your code
<Odometer />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useLayoutEffect, useRef, useState } from "react";
import { gsap } from "gsap";

// Numbers that roll digit by digit, like the wheels of an odometer. Every digit is a window one
// character tall with a tall strip of 0-9 behind it; rolling is just sliding that strip up.
// The strip is 40 digits long (four full 0-9 turns) so a digit can spin around a few times before
// it lands. Separators, the prefix and the suffix stay still while the wheels turn.

const CELLS = 40;

export type OdometerProps = {
  /** The number to show once the wheels stop. */
  value?: number;
  /** The number the wheels start from the first time they roll. */
  from?: number;
  /** How long one wheel takes to roll, in seconds. */
  duration?: number;
  /** How long each wheel waits after the one on its left, in seconds. */
  stagger?: number;
  /** Text before the number, e.g. "$". */
  prefix?: string;
  /** Text after the number, e.g. "%" or " subscribers". */
  suffix?: string;
  /** How many digits to show after the decimal mark. */
  decimals?: number;
  /** What goes between groups of thousands, e.g. "," or " ". Empty for none. */
  separator?: string;
  /** The decimal mark, e.g. "." or ",". */
  decimalMark?: string;
  /** When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. */
  trigger?: "inView" | "load" | "change";
  /** The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". */
  ease?: string;
  /** Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. */
  className?: string;
  /** Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". */
  affixClassName?: string;
};

type Piece = { kind: "digit"; digit: number; fromRight: number } | { kind: "text"; char: string };

// "12480" → "12,480" as a list of digit wheels and fixed characters, left to right
function buildPieces(value: number, decimals: number, separator: string, decimalMark: string): Piece[] {
  const places = Math.min(6, Math.max(0, Math.round(decimals)));
  const [whole = "0", fraction = ""] = Math.abs(value).toFixed(places).split(".");
  const grouped = separator ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, "\u0000") : whole;
  const characters = [...(value < 0 ? "-" : ""), ...grouped, ...(places > 0 ? ["\u0001", ...fraction] : [])];
  const digitCount = characters.filter((c) => c >= "0" && c <= "9").length;
  let seen = 0;
  return characters.map((c): Piece => {
    if (c >= "0" && c <= "9") {
      seen += 1;
      return { kind: "digit", digit: Number(c), fromRight: digitCount - seen };
    }
    return { kind: "text", char: c === "\u0000" ? separator : c === "\u0001" ? decimalMark : c };
  });
}

// The digits of `from`, left-padded with zeros (or trimmed) to fit the wheels we have
function startDigits(from: number, decimals: number, count: number): number[] {
  const places = Math.min(6, Math.max(0, Math.round(decimals)));
  const digits = Math.abs(from).toFixed(places).replace(".", "").split("").map(Number);
  while (digits.length < count) digits.unshift(0);
  return digits.slice(digits.length - count);
}

// The wheels fade out at the top and bottom edge of their window, so digits slide in softly.
// The digit at rest sits in the clear middle.
const fade = { maskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)" };

const wrap = (n: number) => ((n % 10) + 10) % 10;
const percentFor = (index: number) => -(index / CELLS) * 100;

export function Odometer({
  value = 12480,
  from = 0,
  duration = 1.6,
  stagger = 0.06,
  prefix = "",
  suffix = "",
  decimals = 0,
  separator = ",",
  decimalMark = ".",
  trigger = "inView",
  ease = "power3.out",
  className = "",
  affixClassName = "",
}: OdometerProps) {
  const boxRef = useRef<HTMLSpanElement>(null);
  // The number is drawn as plain text on the server and until the first layout effect, so it
  // is readable without JavaScript and never flashes the wrong digits
  const [ready, setReady] = useState(false);
  // False until the first full roll has finished. Until then every wheel spins, even a 0 that stays 0.
  const firstRollPending = useRef(true);

  const pieces = buildPieces(value, decimals, separator, decimalMark);
  const plain = pieces.map((p) => (p.kind === "digit" ? p.digit : p.char)).join("");
  const spoken = `${prefix}${plain}${suffix}`;
  const digitCount = pieces.filter((p) => p.kind === "digit").length;
  // New format = new wheels (a different number of digits, separator or decimals)
  const layoutKey = `${digitCount}|${decimals}|${separator}|${decimalMark}`;

  useLayoutEffect(() => setReady(true), []);

  useLayoutEffect(() => {
    const box = boxRef.current;
    if (!ready || !box) return;
    const strips = Array.from(box.querySelectorAll<HTMLElement>("[data-wheel]"));
    if (strips.length === 0) return;

    const end = pieces.filter((p) => p.kind === "digit").map((p) => p.digit);
    const start = startDigits(from, decimals, end.length);
    const indexOf = (strip: HTMLElement) => (-Number(gsap.getProperty(strip, "yPercent")) / 100) * CELLS;
    const show = (strip: HTMLElement, index: number) => gsap.set(strip, { yPercent: percentFor(index) });

    // Wheels that were just created sit on their start digit (or on the value, if it only rolls on change)
    const fresh = strips.some((strip) => !strip.dataset.set);
    if (fresh) {
      strips.forEach((strip, k) => {
        strip.dataset.set = "1";
        show(strip, trigger === "change" ? end[k]! : start[k]!);
      });
      firstRollPending.current = trigger !== "change";
    }

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      strips.forEach((strip, k) => show(strip, end[k]!));
      firstRollPending.current = false;
      return;
    }

    let timeline: gsap.core.Timeline | null = null;
    let observer: IntersectionObserver | null = null;

    const roll = () => {
      const forced = firstRollPending.current;
      const current = strips.map((strip) => wrap(indexOf(strip)));
      const distance = end.map((digit, k) => wrap(digit - current[k]!));
      // Like a real odometer, every wheel to the right of the first one that changes turns too
      let first = distance.findIndex((d) => d > 0.001);
      if (forced) first = 0;
      if (first === -1) return;

      timeline = gsap.timeline({
        onComplete: () => {
          firstRollPending.current = false;
        },
      });
      strips.forEach((strip, k) => {
        if (k < first) return;
        const fromRight = strips.length - 1 - k;
        // How many extra full turns this wheel makes: the right-most wheels spin the most
        const turns = forced ? (fromRight === 0 ? 2 : 1) : distance[k]! > 0.001 ? (fromRight === 0 && k > first ? 1 : 0) : 1;
        const travel = distance[k]! + turns * 10;
        show(strip, current[k]!);
        timeline!.to(
          strip,
          {
            yPercent: percentFor(current[k]! + travel),
            duration,
            ease,
            // Back onto the first 0-9 so the strip never runs out
            onComplete: () => show(strip, end[k]!),
          },
          (k - first) * stagger,
        );
      });
    };

    if (firstRollPending.current && trigger === "inView") {
      observer = new IntersectionObserver(
        ([entry]) => {
          if (!entry?.isIntersecting) return;
          observer?.disconnect();
          roll();
        },
        { threshold: 0.3 },
      );
      observer.observe(box);
    } else {
      roll();
    }

    return () => {
      observer?.disconnect();
      timeline?.kill();
    };
    // `pieces` changes every render but plain/layoutKey cover everything that matters in it
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, plain, layoutKey, from, trigger, duration, stagger, ease]);

  const affix = (text: string) =>
    text ? (
      <span aria-hidden className={`whitespace-pre leading-none ${affixClassName}`}>
        {text}
      </span>
    ) : null;

  return (
    <span ref={boxRef} className={`inline-flex items-center leading-none whitespace-pre ${className}`} style={{ fontVariantNumeric: "tabular-nums", fontFeatureSettings: '"tnum"' }}>
      <span className="sr-only">{spoken}</span>
      {affix(prefix)}
      <span key={ready ? layoutKey : "static"} aria-hidden className="inline-flex items-center leading-none">
        {ready
          ? pieces.map((piece, i) =>
              piece.kind === "text" ? (
                <span key={`t${i}`} className="inline-block leading-none">
                  {piece.char}
                </span>
              ) : (
                // The window: one digit tall. The 0-9 strip slides behind it.
                <span key={`d${piece.fromRight}`} className="-mx-[0.04em] inline-block h-[1em] overflow-hidden px-[0.04em] leading-none" style={fade}>
                  <span data-wheel className="block will-change-transform">
                    {Array.from({ length: CELLS }, (_, n) => (
                      <span key={n} className="block h-[1em] leading-none">
                        {n % 10}
                      </span>
                    ))}
                  </span>
                </span>
              ),
            )
          : plain}
      </span>
      {affix(suffix)}
    </span>
  );
}

The component. Installs to components/dimaac/odometer.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/odometer.json

It adds components/dimaac/odometer.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/odometer.

By hand

npm install gsap
  1. Install the packages it uses: gsap.
  2. Copy the component file below into components/dimaac/odometer.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { Odometer } from "@/components/dimaac/odometer" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
valuenumber12480The number to show once the wheels stop.
fromnumber0The number the wheels start from the first time they roll.
durationnumber1.6How long one wheel takes to roll, in seconds.
staggernumber0.06How long each wheel waits after the one on its left, in seconds.
prefixstring""Text before the number, e.g. "$".
suffixstring""Text after the number, e.g. "%" or " subscribers".
decimalsnumber0How many digits to show after the decimal mark.
separatorstring","What goes between groups of thousands, e.g. "," or " ". Empty for none.
decimalMarkstring"."The decimal mark, e.g. "." or ",".
trigger"inView" | "load" | "change""inView"When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen.
easestring"power3.out"The GSAP ease of each wheel, e.g. "power3.out" or "expo.out".
classNamestring""Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em.
affixClassNamestring""Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]".

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Odometer component (<Odometer />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/odometer
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/odometer.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/odometer.json

Then use it like this:
import { Odometer } from "@/components/dimaac/odometer";
<Odometer />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/odometer.tsx and install gsap.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
odometer.md
This page as Markdown: install, props and the full source
odometer.json
The shadcn registry item

About

A counter whose digits are wheels. When the number scrolls into view, loads, or changes, every digit rolls up to its new value, the left-most wheel settling first and the right-most spinning the longest. It formats like a real number (thousands separator, decimal places, a prefix and a suffix), uses tabular figures so nothing shifts while it turns, and keeps the real number in screen-reader text. A new value rolls from what is on screen, and with reduced motion it simply shows the number. Generalised from the rolling counter in the GSAP course image loader.

Installs to
components/dimaac/odometer.tsx
Uses
gsap
Tags
gsap, counter, numbers, stats