# Odometer Number (`odometer-number`)

> An odometer for stats and prices — each digit is a rolling column that springs to its target with a mechanical overshoot, left to right.

- **Docs:** https://www.mellowui.com/components/odometer-number
- **Markdown:** https://www.mellowui.com/components/odometer-number.md
- **Registry:** https://www.mellowui.com/r/odometer-number.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/odometer-number
- **Categories:** text, display, animation, interactive
- **Dependencies:** motion

## AI prompt

Add an OdometerNumber component from the mellow library — a stat / price counter where every digit is a rolling column that springs to its target with a slight mechanical overshoot, staggered left to right. Pass `value` (changing it re-rolls from the previous digits), `prefix` / `suffix` for currency or units, `decimals`, and `trigger` ('scroll' rolls up on first view, 'mount' immediately). When the value re-rolls it plays a decelerating mechanical ratchet (`sound`, on by default) and vibrates on supported devices (`haptics`, on by default); set `confetti` to fling ticker-tape when the value increases — perfect for a 'deal closed' moment. Feedback is gated to changes after the first reveal, so nothing fires on load. Grouping separators come from `locale` via Intl.NumberFormat. Size it with text classes via `className` — digits inherit the font. Screen readers get the plain formatted number; respects prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/odometer-number.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` | `number` | `12500` | The number to display. Changing it re-rolls the digits. |
| `from` | `number` | `0` | Value the digits roll up from on first reveal. |
| `trigger` | `"scroll" \| "mount"` | `"scroll"` | Start when scrolled into view, or immediately on mount. |
| `locale` | `string \| false` | — | Locale for grouping separators; false disables formatting. |
| `decimals` | `number` | `0` | Decimal places. |
| `prefix` | `string` | — | Rendered before the number, e.g. '$'. |
| `suffix` | `string` | — | Rendered after the number, e.g. '%'. |
| `stagger` | `number` | `0.06` | Per-column delay in seconds — left columns settle first. |
| `stiffness` | `number` | `110` | Roll spring stiffness — higher snaps harder. |
| `damping` | `number` | `16` | Roll spring damping — lower overshoots more. |
| `sound` | `boolean` | `true` | Play a decelerating mechanical ratchet when the value re-rolls (not on first reveal). |
| `haptics` | `boolean` | `true` | Vibrate on re-roll where supported (mobile). |
| `confetti` | `boolean` | `false` | Fling ticker-tape confetti when the value increases. |
| `className` | `string` | — | Additional CSS classes — set the font size here. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";

export interface OdometerNumberProps {
  value: number;
  /** Value the digits roll up from on first reveal. */
  from?: number;
  /** Start when scrolled into view, or immediately on mount. */
  trigger?: "scroll" | "mount";
  /** Locale for grouping separators; false disables formatting. */
  locale?: string | false;
  /** Decimal places. */
  decimals?: number;
  prefix?: string;
  suffix?: string;
  /** Per-column stagger in seconds — left columns settle first. */
  stagger?: number;
  /** Roll spring stiffness — higher snaps harder. */
  stiffness?: number;
  /** Roll spring damping — lower overshoots more. */
  damping?: number;
  /** Mechanical ratchet clicks when the value re-rolls (not on first reveal). */
  sound?: boolean;
  /** Vibrate on re-roll where supported (mobile). */
  haptics?: boolean;
  /** Fling ticker-tape confetti when the value increases. */
  confetti?: boolean;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * An odometer for stats and prices — each digit is a rolling column that
 * springs to its target with a mechanical overshoot, left to right.
 * Re-rolls whenever `value` changes, with optional ratchet sound, haptics,
 * and a ticker-tape confetti burst on the way up.
 */
export function OdometerNumber({
  value,
  from = 0,
  trigger = "scroll",
  locale,
  decimals = 0,
  prefix = "",
  suffix = "",
  stagger = 0.06,
  stiffness = 110,
  damping = 16,
  sound = true,
  haptics = true,
  confetti = false,
  className,
  style,
}: OdometerNumberProps) {
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.5 });
  const reduced = useReducedMotion();
  const started = trigger === "mount" || inView;
  const shown = started ? value : from;

  const formatted = useMemo(() => {
    if (locale === false) return shown.toFixed(decimals);
    return new Intl.NumberFormat(locale, {
      minimumFractionDigits: decimals,
      maximumFractionDigits: decimals,
    }).format(shown);
  }, [shown, locale, decimals]);

  // Previous digits, right-aligned, so new columns roll in from the digit
  // that used to occupy their place value.
  const prevDigitsRef = useRef<number[]>([]);
  useEffect(() => {
    const digits: number[] = [];
    for (let i = formatted.length - 1; i >= 0; i--) {
      if (/\d/.test(formatted[i])) digits.push(Number(formatted[i]));
    }
    prevDigitsRef.current = digits;
  }, [formatted]);

  const audioCtxRef = useRef<AudioContext | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const particlesRef = useRef<Particle[]>([]);
  const rafRef = useRef<number | null>(null);

  const runConfetti = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const cx = CANVAS_W / 2;
    const cy = CANVAS_H * 0.58;
    for (let i = 0; i < 52; i++) {
      const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.95;
      const speed = 3.2 + Math.random() * 4.8;
      particlesRef.current.push({
        x: cx + (Math.random() - 0.5) * 40,
        y: cy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 1.2,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        flip: Math.random() * Math.PI,
        vf: 0.18 + Math.random() * 0.22,
        w: 2 + Math.random() * 2.5,
        h: 7 + Math.random() * 7,
        life: 70 + Math.random() * 40,
        color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
      });
    }
    if (rafRef.current == null) {
      const loop = () => {
        const c = canvasRef.current;
        const ctx = c?.getContext("2d");
        if (!c || !ctx) {
          rafRef.current = null;
          return;
        }
        ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
        const ps = particlesRef.current;
        for (let i = ps.length - 1; i >= 0; i--) {
          const p = ps[i];
          p.vy += 0.16;
          p.vx *= 0.995;
          p.x += p.vx;
          p.y += p.vy;
          p.rot += p.vr;
          p.flip += p.vf;
          p.life -= 1;
          if (p.life <= 0 || p.y > CANVAS_H + 20) {
            ps.splice(i, 1);
            continue;
          }
          ctx.save();
          ctx.translate(p.x, p.y);
          ctx.rotate(p.rot);
          ctx.scale(1, Math.cos(p.flip));
          ctx.globalAlpha = 0.85 * Math.min(1, p.life / 26);
          ctx.fillStyle = p.color;
          ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
          ctx.restore();
        }
        rafRef.current = ps.length > 0 ? requestAnimationFrame(loop) : null;
      };
      rafRef.current = requestAnimationFrame(loop);
    }
  }, []);

  // Fire feedback only when the value re-rolls after the first reveal.
  const revealedRef = useRef(false);
  const prevValueRef = useRef(value);
  useEffect(() => {
    if (!started) return;
    if (!revealedRef.current) {
      revealedRef.current = true;
      prevValueRef.current = value;
      return;
    }
    const delta = value - prevValueRef.current;
    prevValueRef.current = value;
    if (delta === 0) return;

    if (sound) {
      try {
        const AC =
          window.AudioContext ||
          (window as unknown as { webkitAudioContext: typeof AudioContext })
            .webkitAudioContext;
        if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
          audioCtxRef.current = new AC();
        }
        const ctx = audioCtxRef.current;
        const play = () => playRatchet(ctx);
        ctx.state === "suspended" ? void ctx.resume().then(play) : play();
      } catch {
        // audio unavailable — the roll alone carries the feedback
      }
    }
    if (haptics && typeof navigator !== "undefined" && "vibrate" in navigator) {
      navigator.vibrate([6, 28, 6, 42, 5, 58, 4]);
    }
    if (confetti && delta > 0 && !reduced) runConfetti();
  }, [value, started, sound, haptics, confetti, reduced, runConfetti]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!confetti || !canvas) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = CANVAS_W * dpr;
    canvas.height = CANVAS_H * dpr;
    canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
  }, [confetti]);

  useEffect(
    () => () => {
      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
      void audioCtxRef.current?.close().catch(() => {});
    },
    []
  );

  const chars = formatted.split("");
  const totalDigits = chars.filter((c) => /\d/.test(c)).length;
  let seenFromRight = 0;
  const columns = [...chars].reverse().map((ch, ri) => {
    const isDigit = /\d/.test(ch);
    const idxFromRight = isDigit ? seenFromRight++ : -1;
    return { ch, isDigit, idxFromRight, key: isDigit ? `d${idxFromRight}` : `c${ri}` };
  });
  columns.reverse();

  return (
    <span
      ref={ref}
      aria-label={`${prefix}${formatted}${suffix}`}
      className={["relative inline-flex items-baseline tabular-nums", className]
        .filter(Boolean)
        .join(" ")}
      style={style}
    >
      <span aria-hidden="true" className="inline-flex items-baseline">
        {prefix && <span>{prefix}</span>}
        {columns.map((col) =>
          col.isDigit ? (
            <DigitColumn
              key={col.key}
              digit={Number(col.ch)}
              start={prevDigitsRef.current[col.idxFromRight] ?? 0}
              delay={(totalDigits - 1 - col.idxFromRight) * stagger}
              stiffness={stiffness}
              damping={damping}
              reduced={!!reduced}
            />
          ) : (
            <span key={col.key}>{col.ch}</span>
          )
        )}
        {suffix && <span>{suffix}</span>}
      </span>
      {confetti && (
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className="pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
          style={{ width: CANVAS_W, height: CANVAS_H }}
        />
      )}
    </span>
  );
}

const CANVAS_W = 360;
const CANVAS_H = 260;

const CONFETTI_COLORS = [
  "rgb(232, 72, 72)",   // red
  "rgb(255, 168, 48)",  // amber
  "rgb(255, 220, 64)",  // yellow
  "rgb(72, 196, 112)",  // green
  "rgb(45, 110, 240)",  // blue
  "rgb(168, 88, 232)",  // violet
  "rgb(232, 88, 168)",  // pink
  "rgb(255, 128, 88)",  // coral
];

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  rot: number;
  vr: number;
  flip: number;
  vf: number;
  w: number;
  h: number;
  life: number;
  color: string;
}

/** A rapid, decelerating burst of mechanical ticks — a counter ratcheting home. */
function playRatchet(ctx: AudioContext) {
  const count = 13;
  const now = ctx.currentTime;
  for (let i = 0; i < count; i++) {
    const p = i / (count - 1);
    const at = now + 0.5 * (1 - Math.pow(1 - p, 2.2));
    const dur = 0.012;
    const len = Math.floor(ctx.sampleRate * dur);
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let j = 0; j < len; j++) {
      data[j] = (Math.random() * 2 - 1) * Math.pow(1 - j / len, 6);
    }
    const src = ctx.createBufferSource();
    src.buffer = buf;
    const bp = ctx.createBiquadFilter();
    bp.type = "bandpass";
    bp.frequency.value = 2600;
    bp.Q.value = 0.8;
    const g = ctx.createGain();
    g.gain.value = 0.2 * (1 - p * 0.45);
    src.connect(bp);
    bp.connect(g);
    g.connect(ctx.destination);
    src.start(at);
  }
}

function DigitColumn({
  digit,
  start,
  delay,
  stiffness,
  damping,
  reduced,
}: {
  digit: number;
  start: number;
  delay: number;
  stiffness: number;
  damping: number;
  reduced: boolean;
}) {
  return (
    <span className="inline-block h-[1em] overflow-hidden">
      <motion.span
        initial={{ y: `${-start}em` }}
        animate={{ y: `${-digit}em` }}
        transition={
          reduced
            ? { duration: 0 }
            : { type: "spring", stiffness, damping, delay }
        }
        className="flex flex-col"
      >
        {Array.from({ length: 10 }, (_, i) => (
          <span key={i} className="block h-[1em] leading-none">
            {i}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

export default OdometerNumber;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { OdometerNumber } from "../mellow/odometer-number";

export default function OdometerNumberDemo() {
  const [revenue, setRevenue] = useState(1284905);

  return (
    <div className="flex flex-col items-center gap-8 p-8">
      <div className="flex flex-col items-center gap-2">
        <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
          Annual recurring revenue
        </span>
        <OdometerNumber
          value={revenue}
          prefix="$"
          trigger="mount"
          confetti
          className="[font-family:var(--font-sans)] text-5xl font-medium tracking-[-0.03em] text-[var(--ink)]"
        />
      </div>

      <div className="flex items-center gap-10">
        <div className="flex flex-col items-center gap-1">
          <OdometerNumber
            value={99.98}
            decimals={2}
            suffix="%"
            trigger="mount"
            className="[font-family:var(--font-sans)] text-2xl font-medium text-[var(--ink)]"
          />
          <span className="text-[0.75rem] text-[rgba(var(--ink-rgb),0.4)]">uptime</span>
        </div>
        <div className="flex flex-col items-center gap-1">
          <OdometerNumber
            value={31}
            suffix=" components"
            trigger="mount"
            className="[font-family:var(--font-sans)] text-2xl font-medium text-[var(--ink)]"
          />
          <span className="text-[0.75rem] text-[rgba(var(--ink-rgb),0.4)]">and counting</span>
        </div>
      </div>

      <button
        type="button"
        onClick={() => setRevenue((r) => r + Math.floor(Math.random() * 90000) + 10000)}
        className="cursor-pointer border border-[var(--rule)] px-2.5 py-1 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase transition-colors hover:bg-[rgba(var(--ink-rgb),0.06)]"
      >
        Close a deal
      </button>
    </div>
  );
}

```
