Odometer Stat
Rolling digits for real numbers: each digit spins once into place when visible, and screen readers get the value.
npx shadcn@latest add @vibebox/odometer-statControls
<OdometerStat />About
A statistic that rolls into place. When at least half of it is on screen, every digit spins one full turn and lands on its value, the left-hand places settling a little later than the right, like a mechanical counter coming to rest. The spin starts and ends on the digit the server rendered, so the number is correct on the first paint, without JavaScript and under reduced motion, and it never flashes through zero. Formatting comes from Intl.NumberFormat: currency, percentages, grouping and decimals follow the locale. Original to VibeBox Motion.
Use it when
- A section shows a few real, verifiable numbers: bookings, years, branches, funds raised.
- A proof band needs life without a chart.
Skip it when
- The number is not real or cannot be checked; never invent statistics.
- The value changes every second (a live ticker reads better as plain text).
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/odometer-stat - 3Use it:
import { OdometerStat } from "@/components/vibebox/odometer-stat"; const pesos: Intl.NumberFormatOptions = { style: "currency", currency: "PHP", maximumFractionDigits: 0 }; <OdometerStat value={1240000} format={pesos} label="raised for the school library" className="text-5xl" />
Code
2 filescomponents/vibebox/odometer-stat/index.tsx
/**
* VibeBox Motion · Odometer Stat
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { OdometerTrigger } from "./odometer-stat.client";
/*
* A number that rolls when it comes into view. Each digit spins one full turn and lands on the
* digit the server already rendered, so the first paint, a page without JavaScript and reduced
* motion all show the real value, and nothing ever flashes to zero. Screen readers get the
* formatted value as one string.
*/
const DIGITS = Array.from({ length: 20 }, (_, index) => index % 10);
const STYLES = `
[data-vb-odo-strip] { transform: translateY(calc(var(--vb-odo-digit) * -1.25em)); }
[data-vb="odometer-stat"][data-state="spun"] [data-vb-odo-strip] { transform: translateY(calc((var(--vb-odo-digit) + 10) * -1.25em)); }
@media (prefers-reduced-motion: no-preference) {
[data-vb="odometer-stat"][data-state="spun"] [data-vb-odo-strip] {
transition: transform calc(var(--vb-duration-section, 520ms) * 2.6 + var(--vb-odo-index) * var(--vb-duration-micro, 140ms)) var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1));
}
}
`;
export type OdometerStatProps = {
/** The real number. */
value: number;
/** BCP 47 locale for grouping and decimals. */
locale?: string;
/** Intl.NumberFormat options, for example { style: "currency", currency: "PHP" } or { maximumFractionDigits: 1 }. */
format?: Intl.NumberFormatOptions;
/** Text before and after the number, such as "+" or "%". */
prefix?: string;
suffix?: string;
/** What the number counts, shown under it. */
label?: string;
className?: string;
};
export function OdometerStat({ value, locale = "en-US", format, prefix = "", suffix = "", label, className }: OdometerStatProps) {
const parts = new Intl.NumberFormat(locale, format).formatToParts(value);
const text = `${prefix}${parts.map((part) => part.value).join("")}${suffix}`;
let index = 0;
return (
<div data-vb="odometer-stat" data-state="rest" className={cn("flex flex-col gap-1", className)}>
<style href="vb-odometer-stat" precedence="vb">
{STYLES}
</style>
<span className="sr-only">
{text}
{label ? ` ${label}` : ""}
</span>
{/* Every piece sits in a box one row tall (1.25em leaves room for any font's ascenders and
descenders), so digits and separators share a baseline and rows never overlap. */}
<span aria-hidden="true" className="inline-flex items-start leading-[1.25] font-semibold tracking-tight tabular-nums">
{prefix && <span className="block h-[1.25em] shrink-0">{prefix}</span>}
{parts.flatMap((part, partIndex) =>
part.type === "integer" || part.type === "fraction"
? [...part.value].map((char, charIndex) => {
const digit = Number(char);
const column = index++;
return (
<span key={`${partIndex}-${charIndex}`} className="block h-[1.25em] shrink-0 overflow-hidden">
<span
data-vb-odo-strip=""
className="flex flex-col"
style={{ "--vb-odo-digit": digit, "--vb-odo-index": column } as CSSProperties}
>
{DIGITS.map((shown, row) => (
<span key={row} className="block h-[1.25em]">
{shown}
</span>
))}
</span>
</span>
);
})
: [
<span key={partIndex} className="block h-[1.25em] shrink-0 whitespace-pre">
{part.value}
</span>,
],
)}
{suffix && <span className="block h-[1.25em] shrink-0">{suffix}</span>}
</span>
{label && (
<span aria-hidden="true" className="text-small text-muted-foreground">
{label}
</span>
)}
<OdometerTrigger />
</div>
);
}components/vibebox/odometer-stat/odometer-stat.client.tsx
/**
* VibeBox Motion · Odometer Stat
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useEffect, useRef } from "react";
/** Spins the digits once, when at least half the number is on screen. */
export function OdometerTrigger() {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = ref.current?.closest<HTMLElement>('[data-vb="odometer-stat"]');
if (!root) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
root.dataset.state = "spun";
observer.disconnect();
},
{ threshold: 0.5 },
);
observer.observe(root);
return () => observer.disconnect();
}, []);
return <span ref={ref} hidden />;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | number | — | The real number. Never invent one. |
| locale | string | "en-US" | BCP 47 locale for grouping and decimals. |
| format | Intl.NumberFormatOptions | — | For example { style: "currency", currency: "PHP" } or { maximumFractionDigits: 1 }. |
| prefix | string | — | Text before the number, such as "+". |
| suffix | string | — | Text after the number, such as "%". |
| label | string | — | What the number counts, shown under it. |
| className | string | — | Merged with cn(); set the type size on the root. |
Accessibility and motion
- Keyboard
- Not focusable; it is text.
- Screen readers
- One string with the formatted value and its label; the digit strips are hidden.
- Touch
- Nothing to touch.
- Reduced motion
- The number is shown as is; no spin.
Edge cases and limits
- Values with many digits spin longer at the left; keep stats short.
- Compact notation (
notation: "compact") works: "1.2M" spins its digits and keeps the letter static.
Related

Kinetic Headline
FreeA headline that rises into place word by word behind a mask, on CSS alone, with serif and spectrum accents.
Text · CSS

Liquid Word Swap
FreeOne word in a headline cycles through options with a liquid morph: "Websites that sell, book, grow".
Text · CSS