Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

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.

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/odometer-number-demo.tsx

File content: "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-5 p-4 sm:gap-8 sm: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-4xl font-medium tracking-[-0.03em] sm:text-5xl text-[var(--ink)]"
        />
      </div>

      <div className="flex flex-wrap items-center justify-center gap-6 sm: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-xl font-medium text-[var(--ink)] sm:text-2xl"
          />
          <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="\u00a0components"
            trigger="mount"
            className="[font-family:var(--font-sans)] text-xl font-medium text-[var(--ink)] sm:text-2xl"
          />
          <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>
  );
}



File location: components/mellow/odometer-number.tsx

File content: "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>
  );
}

// 320 keeps the burst inside a phone-width preview frame
const CANVAS_W = 320;
const CANVAS_H = 240;

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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI