Currency with cents, a count, a percentage and milliseconds. The range switcher makes big jumps in both directions; between them the numbers drift live while the cards are on screen, and the delta chips flip color and arrow with their sign.
@keyframeui
registry/demos/odometer-dashboard-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 Range = "24h" | "7d" | "30d";
type Reading = { value: number; delta: number };
const ranges: { id: Range; label: string }[] = [
{ id: "24h", label: "24 hours" },
{ id: "7d", label: "7 days" },
{ id: "30d", label: "30 days" },
];
const metrics: {
id: string;
label: string;
format: Intl.NumberFormatOptions;
/** Rounds a drifting value to what the format shows. */
round: (n: number) => number;
/** How far one idle tick can drift, as a share of the value. */
wobble: number;
/** Lower is better, so a fall is shown in green. */
invert?: boolean;
data: Record<Range, Reading>;
}[] = [
{
id: "revenue",
label: "Revenue",
format: { style: "currency", currency: "USD", minimumFractionDigits: 2 },
round: (n) => Math.round(n * 100) / 100,
wobble: 0.003,
data: {
"24h": { value: 48210.5, delta: 0.124 },
"7d": { value: 312904.2, delta: 0.081 },
"30d": { value: 1284377.9, delta: 0.216 },
},
},
{
id: "users",
label: "Active users",
format: {},
round: Math.round,
wobble: 0.002,
data: {
"24h": { value: 12842, delta: 0.038 },
"7d": { value: 86315, delta: 0.052 },
"30d": { value: 341208, delta: 0.149 },
},
},
{
id: "conversion",
label: "Conversion",
format: { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 },
round: (n) => Math.round(n * 10000) / 10000,
wobble: 0.01,
data: {
"24h": { value: 0.0342, delta: 0.006 },
"7d": { value: 0.0318, delta: -0.024 },
"30d": { value: 0.0296, delta: -0.012 },
},
},
{
id: "response",
label: "Avg. response",
format: { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 0 },
round: Math.round,
wobble: 0.025,
invert: true,
data: {
"24h": { value: 184, delta: -0.031 },
"7d": { value: 201, delta: 0.047 },
"30d": { value: 176, delta: -0.063 },
},
},
];
const deltaFormat: Intl.NumberFormatOptions = {
style: "percent",
signDisplay: "exceptZero",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
};
const baseline = (range: Range) => metrics.map((m) => m.data[range]);
/** One idle tick: each reading wanders a little and is pulled back toward its range's baseline. */
function drift(readings: Reading[], range: Range, noise: number[][]): Reading[] {
return readings.map((reading, i) => {
const m = metrics[i];
const base = m.data[range];
const [a, b] = noise[i];
return {
value: m.round(reading.value + (base.value - reading.value) * 0.2 + base.value * m.wobble * (a * 2 - 1)),
delta: Math.round((reading.delta + (base.delta - reading.delta) * 0.2 + 0.006 * (b * 2 - 1)) * 1000) / 1000,
};
});
}
// The range switcher makes big jumps; between them the numbers drift every
// couple of seconds, only while the dashboard is on screen.
export function OdometerDashboardDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const live = useInView(ref, { amount: 0.3 });
const [range, setRange] = React.useState<Range>("24h");
const [readings, setReadings] = React.useState(() => baseline("24h"));
React.useEffect(() => {
if (!live) return;
let timer = 0;
const tick = () => {
const noise = metrics.map(() => [Math.random(), Math.random()]);
setReadings((current) => drift(current, range, noise));
timer = window.setTimeout(tick, 1600 + Math.random() * 900);
};
timer = window.setTimeout(tick, 1600 + Math.random() * 900);
return () => window.clearTimeout(timer);
}, [live, range]);
const choose = (next: Range) => {
setRange(next);
setReadings(baseline(next));
};
const index = ranges.findIndex((r) => r.id === range);
return (
<div ref={ref} className="@container w-full max-w-5xl">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">Overview</h3>
<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>
<div
role="radiogroup"
aria-label="Time range"
className="relative grid grid-cols-3 rounded-lg border border-zinc-200 bg-zinc-100 p-0.5 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-0.5 left-0.5 w-[calc((100%-0.25rem)/3)] rounded-md 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 }}
/>
{ranges.map((r) => (
<button
key={r.id}
type="button"
role="radio"
aria-checked={r.id === range}
onClick={() => choose(r.id)}
className={cn(
"relative min-w-12 rounded-md px-3 py-1 font-mono text-xs transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400",
r.id === range
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
)}
>
{r.id}
</button>
))}
</div>
</div>
<div className="mt-5 grid gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
{metrics.map((m, i) => {
const { value, delta } = readings[i];
const good = m.invert ? delta <= 0 : delta >= 0;
return (
<div
key={m.id}
className="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-950/[0.03] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{m.label}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium transition-colors duration-300",
good
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
)}
>
<motion.svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3"
initial={false}
animate={{ rotate: delta < 0 ? 180 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 22 }}
>
<path d="M6 9.5v-7M3 5.5l3-3 3 3" />
</motion.svg>
<Odometer value={delta} format={deltaFormat} />
</span>
</div>
<p className="mt-4 text-[1.75rem] leading-none font-semibold tracking-[-0.03em] text-zinc-950 dark:text-zinc-50">
<Odometer value={value} format={m.format} />
</p>
<p className="mt-3 text-xs text-zinc-500 dark:text-zinc-500">
vs. previous {ranges[index].label}
</p>
</div>
);
})}
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-dashboard-demo






















