Odometer

Each digit rolls on its own column whenever the number changes.

Edit with AIPro

Describe a change to Odometer and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/odometer.tsx

    components/ui/odometer.tsx
    "use client";
    
    import { motion } from "motion/react";
    import { Minus, Plus } from "lucide-react";
    import { useState } from "react";
    
    function Digit({ digit }: { digit: number }) {
      return (
        <span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden" aria-hidden>
          <motion.span className="absolute left-0 top-0 flex flex-col" animate={{ y: `-${digit}em` }} transition={{ type: "spring", stiffness: 160, damping: 22 }}>
            {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => (
              <span key={d} className="flex h-[1em] items-center justify-center leading-none">
                {d}
              </span>
            ))}
          </motion.span>
        </span>
      );
    }
    
    /** Odometer-style number: each digit rolls on its own column whenever the value changes. */
    export function Odometer({ value, className = "" }: { value: number; className?: string }) {
      const text = Math.max(0, Math.round(value)).toLocaleString("en-US");
      // Key digits from the right so columns stay put when the number gains a digit.
      const chars = text.split("");
      return (
        <span className={`inline-flex items-center tabular-nums ${className}`} role="status" aria-label={text}>
          {chars.map((ch, i) => {
            const key = chars.length - i;
            return /\d/.test(ch) ? <Digit key={key} digit={Number(ch)} /> : <span key={key} aria-hidden>{ch}</span>;
          })}
        </span>
      );
    }
    
    export default function Demo() {
      const [value, setValue] = useState(12849);
      return (
        <div className="flex flex-col items-center gap-6">
          <Odometer value={value} className="text-6xl font-semibold tracking-tight text-zinc-900" />
          <div className="flex gap-2">
            <button onClick={() => setValue((v) => Math.max(0, v - 137))} aria-label="Decrease" className="grid size-10 place-items-center rounded-full border border-zinc-200 hover:bg-zinc-50">
              <Minus className="size-4" />
            </button>
            <button onClick={() => setValue((v) => v + 1)} className="rounded-full border border-zinc-200 px-4 text-sm hover:bg-zinc-50">
              +1
            </button>
            <button onClick={() => setValue((v) => v + 1368)} aria-label="Increase" className="grid size-10 place-items-center rounded-full bg-zinc-900 text-white hover:bg-zinc-800">
              <Plus className="size-4" />
            </button>
          </div>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.