---
title: Odometer
description: Numbers that roll digit by digit, like an odometer.
category: Text
tier: free
url: https://ui.dimaac.com/components/odometer
registry: https://ui.dimaac.com/r/odometer.json
---

# Odometer

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/odometer.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/odometer.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/odometer.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/odometer`

Dependencies:

```bash
pnpm add gsap
npm install gsap
```

By hand:

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.

## Usage

```tsx
import { Odometer } from "@/components/dimaac/odometer";

<Odometer />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | `12480` | The number to show once the wheels stop. |
| `from` | `number` | `0` | The number the wheels start from the first time they roll. |
| `duration` | `number` | `1.6` | How long one wheel takes to roll, in seconds. |
| `stagger` | `number` | `0.06` | How long each wheel waits after the one on its left, in seconds. |
| `prefix` | `string` | `""` | Text before the number, e.g. "$". |
| `suffix` | `string` | `""` | Text after the number, e.g. "%" or " subscribers". |
| `decimals` | `number` | `0` | How many digits to show after the decimal mark. |
| `separator` | `string` | `","` | What goes between groups of thousands, e.g. "," or " ". Empty for none. |
| `decimalMark` | `string` | `"."` | 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. |
| `ease` | `string` | `"power3.out"` | The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". |
| `className` | `string` | `""` | Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. |
| `affixClassName` | `string` | `""` | Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". |

## Source

### odometer.tsx → `components/dimaac/odometer.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { Odometer, type OdometerProps } from "@/components/dimaac/odometer";

// One big number and a row of stats. The playground drives the big number;
// the stats share its timing, separator and trigger.

const stats = [
  { label: "Installs", value: 6495, suffix: "", decimals: 0 },
  { label: "Releases", value: 41, suffix: "", decimals: 0 },
  { label: "Uptime", value: 99.9, suffix: "%", decimals: 1 },
];

export default function Demo(props: Partial<OdometerProps>) {
  // "Roll again" bumps the numbers when the trigger is "change", and replays the roll otherwise
  const [round, setRound] = useState(0);
  const changes = props.trigger === "change";
  const bump = changes ? round : 0;
  const { value = 12480, className, ...shared } = props;

  // The big number shrinks to fit the stage however long the playground makes it
  const shownValue = value + bump * 1337;
  const wholeDigits = Math.floor(Math.abs(shownValue)).toString().length;
  const length = wholeDigits + (props.separator === "" ? 0 : Math.floor((wholeDigits - 1) / 3)) + (props.decimals ? props.decimals + 1 : 0) + (props.prefix?.length ?? 0) + (props.suffix?.length ?? 0);
  const heroSize = `min(240px, ${(88 / (length * 0.56)).toFixed(2)}cqw)`;

  return (
    <div
      className="relative flex h-full w-full flex-col overflow-hidden bg-[#f3f0eb] px-6 py-6 text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4] md:px-12 md:py-9"
      style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&display=swap" precedence="default" />

      <main className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 text-center" style={{ containerType: "inline-size" }}>
        <p className="font-mono text-[11px] tracking-wide uppercase opacity-60">Installs this month</p>
        <div style={{ fontSize: heroSize }} className="flex max-w-full justify-center leading-none">
          <Odometer
            key={changes ? "change" : round}
            {...shared}
            value={shownValue}
            className={`font-medium tracking-[-0.05em] ${className ?? ""}`}
            affixClassName="opacity-45"
          />
        </div>
      </main>

      <div className="grid grid-cols-3 border-t border-current/15">
        {stats.map((stat, i) => (
          <div key={stat.label} className={`flex flex-col gap-2 py-5 md:py-7 ${i > 0 ? "border-l border-current/15 pl-4 md:pl-8" : ""}`}>
            <Odometer
              key={changes ? stat.label : `${stat.label}${round}`}
              value={stat.value + (stat.decimals ? 0 : bump * (i + 1) * 17)}
              decimals={stat.decimals}
              suffix={stat.suffix}
              trigger={props.trigger}
              duration={props.duration}
              stagger={props.stagger}
              ease={props.ease}
              separator={props.separator}
              className="text-[clamp(26px,5.4vw,72px)] font-medium tracking-[-0.04em]"
              affixClassName="opacity-45"
            />
            <span className="font-mono text-[10px] tracking-wide uppercase opacity-60 md:text-[11px]">{stat.label}</span>
          </div>
        ))}
      </div>

      <button
        type="button"
        onClick={() => setRound((r) => r + 1)}
        className="mx-auto mt-5 cursor-pointer rounded-full border border-current/25 px-4 py-2 font-mono text-[11px] tracking-wide uppercase opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
      >
        {changes ? "Change the numbers" : "Roll again"}
      </button>
    </div>
  );
}
```

## Agent prompt

```text
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
```
