Odometer
Each digit rolls on its own column whenever the number changes.
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>
);
}
Edit with AIPro
Describe a change to Odometer and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/odometer.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.