A rupee fundraiser that keeps ticking up. Switch between the Indian system of lakhs and crores (₹2,45,67,890) and international thousands (₹24,567,890): the commas roll to their new places while every digit stays put.
@keyframeui
registry/demos/odometer-grouping-demo.tsx
"use client";
import * as React from "react";
import { motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { Odometer } from "@/components/ui/odometer";
type Grouping = "indian" | "international";
const inr: Intl.NumberFormatOptions = { style: "currency", currency: "INR", maximumFractionDigits: 0 };
const GOAL = 5_00_00_000; // ₹5 crore
const systems: { id: Grouping; label: string; sample: string }[] = [
{ id: "indian", label: "Indian", sample: "12,34,567" },
{ id: "international", label: "International", sample: "1,234,567" },
];
// Gifts keep rolling in while the card is on screen. Switching the system
// only moves the commas: no digit turns.
export function OdometerGroupingDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const live = useInView(ref, { amount: 0.4 });
const [grouping, setGrouping] = React.useState<Grouping>("indian");
const [raised, setRaised] = React.useState(2_45_67_890);
const [donors, setDonors] = React.useState(18_342);
React.useEffect(() => {
if (!live) return;
let timer = 0;
const tick = () => {
// Mostly small gifts, now and then a big one.
const gift = Math.random() < 0.15 ? 25_000 + Math.random() * 2_00_000 : 500 + Math.random() * 9_500;
setRaised((r) => Math.min(GOAL, r + Math.round(gift / 10) * 10));
setDonors((d) => d + 1);
timer = window.setTimeout(tick, 1400 + Math.random() * 1200);
};
timer = window.setTimeout(tick, 1200);
return () => window.clearTimeout(timer);
}, [live]);
const index = systems.findIndex((s) => s.id === grouping);
return (
<div ref={ref} className="flex w-full max-w-md flex-col items-center">
<div className="w-full rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-950/[0.03] sm:p-7 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-zinc-500 dark:text-zinc-400">Raised for village libraries</p>
<span className="inline-flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="relative flex size-1.5">
{live ? (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
) : null}
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<p className="mt-3 text-[clamp(2.25rem,9vw,3.25rem)] leading-none font-semibold tracking-[-0.04em] text-zinc-950 dark:text-zinc-50">
<Odometer value={raised} format={inr} grouping={grouping} />
</p>
<div className="mt-6 h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<motion.div
className="h-full origin-left rounded-full bg-emerald-500"
initial={false}
animate={{ scaleX: raised / GOAL }}
transition={{ type: "spring", stiffness: 120, damping: 24 }}
/>
</div>
<div className="mt-3 flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm text-zinc-500 dark:text-zinc-400">
<span>
<Odometer
value={donors}
grouping={grouping}
className="font-medium text-zinc-950 dark:text-zinc-50"
/>{" "}
donors
</span>
<span>
of{" "}
<Odometer
value={GOAL}
format={inr}
grouping={grouping}
className="font-medium text-zinc-950 dark:text-zinc-50"
/>{" "}
goal
</span>
</div>
</div>
<div
role="radiogroup"
aria-label="Comma grouping"
className="relative mt-5 grid grid-cols-2 rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
initial={false}
animate={{ x: `${index * 100}%` }}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
/>
{systems.map((system) => (
<button
key={system.id}
type="button"
role="radio"
aria-checked={system.id === grouping}
onClick={() => setGrouping(system.id)}
className={cn(
"relative flex items-baseline justify-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium whitespace-nowrap transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400",
system.id === grouping
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
)}
>
{system.label}
<span className="font-mono text-xs font-normal opacity-60">{system.sample}</span>
</button>
))}
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-grouping-demo






















