A band of headline numbers that roll up from 0 the first time they scroll into view, one after another.
@keyframeui
registry/demos/odometer-stats-demo.tsx
"use client";
import * as React from "react";
import { Odometer } from "@/components/ui/odometer";
const stats: {
value: number;
format?: Intl.NumberFormatOptions;
suffix?: string;
label: string;
}[] = [
{
value: 2_400_000,
format: { notation: "compact", maximumFractionDigits: 1 },
label: "Downloads",
},
{ value: 180, suffix: "+", label: "Countries" },
{
value: 0.9999,
format: { style: "percent", minimumFractionDigits: 2 },
label: "Uptime, last 12 months",
},
{
value: 4.9,
format: { minimumFractionDigits: 1 },
suffix: "/5",
label: "Average rating",
},
];
// Each number waits on 0 until the band scrolls into view, then rolls up,
// a beat after the one before it. Replay remounts the band to roll again.
export function OdometerStatsDemo() {
const [run, setRun] = React.useState(0);
return (
<section className="@container w-full max-w-4xl rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-950/[0.03] sm:p-8 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">By the numbers</p>
<h3 className="mt-2 text-xl font-semibold tracking-tight text-balance text-zinc-950 dark:text-zinc-50">
Shipped by teams in every time zone.
</h3>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ rotate: `${run * 360}deg` }}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</svg>
Replay
</button>
</div>
<dl
key={run}
className="mt-8 grid grid-cols-2 gap-y-8 border-t border-zinc-200 pt-8 @2xl:grid-cols-4 dark:border-zinc-800"
>
{stats.map((stat, i) => (
<div
key={stat.label}
className="flex flex-col-reverse gap-2 border-zinc-200 @2xl:border-l @2xl:px-6 @2xl:first:border-l-0 @2xl:first:pl-0 dark:border-zinc-800"
>
<dt className="text-sm text-zinc-500 dark:text-zinc-400">{stat.label}</dt>
<dd className="text-[clamp(2.25rem,7cqw,3.25rem)] leading-none font-semibold tracking-[-0.045em] text-zinc-950 dark:text-zinc-50">
<Odometer value={stat.value} format={stat.format} rollOnView delay={i * 0.12} />
{stat.suffix ? <span className="text-zinc-400 dark:text-zinc-500">{stat.suffix}</span> : null}
</dd>
</div>
))}
</dl>
</section>
);
}
Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-stats-demo























