Odometer Counter
A mechanical odometer: every digit is a drum that spins, blurs and glows as it rolls, then settles with a detent bounce. Counts up once in view, rolls up or down when the number changes, and slides columns and thousands separators in as it grows. No dependencies.
DOM + CSS0 deps1 file · 13 KB TSXreduced-motion aware
Props
"use client";
import { Fragment, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
/**
* OdometerCounter - a mechanical odometer.
*
* Every digit is its own drum: a strip of 0-9 behind a dark glass window.
* The number counts up when it scrolls into view (once), the fast drums
* blur and glow while they spin, then each drum settles with a small
* detent bounce, the low ones first. Change `target` later and it rolls
* the right way: up for more, down for less. Higher digits only turn when
* the drum beside them carries over, columns slide in as the number grows
* and thousands separators slide in with them.
*
* Size comes from the CSS variable --odo-fs (default 2.25rem); the whole
* thing is built in em. Respects prefers-reduced-motion. React only.
*/
const CELL = 1.2; // drum window height, em
const COL_W = 0.64; // drum width, em
const GAP = 0.06; // space between drums, em
const SEP_W = 0.3; // thousands separator slot, em
const MAX_COLS = 16;
const HEX = /^#[0-9a-fA-F]{6}$/;
// 0-9 then 0 again, so any position 0 <= p < 10 shows a full window.
const STRIP = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
function rgba(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16);
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
function smooth(a: number, b: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
/** Drum position in digits: the units drum is continuous, a higher drum only turns while the one below it carries 9 to 0. */
function drumAt(v: number, k: number): number {
const u = v / Math.pow(10, k);
if (k === 0) return u;
const fl = Math.floor(u);
return fl + smooth(0.88, 1, u - fl);
}
function grouped(n: number, sep: string): string {
const s = String(Math.abs(n));
let out = "";
for (let i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 === 0) out += sep;
out += s.charAt(i);
}
return (n < 0 ? "-" : "") + out;
}
type Drum = {
e: number; // detent spring offset, digits
ev: number;
a: number; // presence 0..1
prev: number;
heat: number;
kickAt: number;
kickV: number;
};
export type OdometerCounterProps = {
/** The number to roll to. Change it later and the drums roll up or down to it. */
target?: number;
/** Seconds for the first count from zero. */
duration?: number;
/** Printed before the number, dimmed. */
prefix?: string;
/** Printed after the number in the accent colour. */
suffix?: string;
/** Thousands separator. */
separator?: string;
/** Accent as #rrggbb: suffix glow and the heat on a spinning drum. */
accent?: string;
className?: string;
style?: CSSProperties;
};
export function OdometerCounter({
target = 12400,
duration = 1.6,
prefix = "",
suffix = "+",
separator = ",",
accent = "#ff5c3c",
className = "",
style,
}: OdometerCounterProps) {
const goal = Number.isFinite(target) ? Math.round(target) : 0;
const need = Math.min(MAX_COLS, String(Math.abs(goal)).length);
const [cols, setCols] = useState(need);
if (need > cols) setCols(need);
const acc = HEX.test(accent) ? accent : "#ff5c3c";
const rootRef = useRef<HTMLSpanElement>(null);
const boxes = useRef<(HTMLSpanElement | null)[]>([]);
const strips = useRef<(HTMLSpanElement | null)[]>([]);
const heats = useRef<(HTMLSpanElement | null)[]>([]);
const seps = useRef<(HTMLSpanElement | null)[]>([]);
const signBox = useRef<HTMLSpanElement>(null);
const cfg = useRef({ goal, duration });
const wake = useRef<(() => void) | null>(null);
useEffect(() => {
cfg.current = { goal, duration };
wake.current?.();
}, [goal, duration]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const drums: Drum[] = Array.from({ length: MAX_COLS }, () => ({
e: 0, ev: 0, a: 0, prev: 0, heat: 0, kickAt: 0, kickV: 0,
}));
let alive = true;
let started = false;
let first = true;
let applied: number | null = null;
let cur = 0;
let signA = 0;
let raf = 0;
let last = 0;
let tw: { from: number; to: number; t0: number; dur: number } | null = null;
const finish = (t: { from: number; to: number }, now: number) => {
const early = t.from + (t.to - t.from) * (1 - Math.pow(0.4, 4));
const dir = Math.abs(t.to) >= Math.abs(t.from) ? 1 : -1;
drums.forEach((d, k) => {
const mov = Math.abs(drumAt(Math.abs(t.to), k) - drumAt(Math.abs(early), k));
if (mov > 0.02) {
d.kickAt = now + k * 55;
d.kickV = (dir * Math.min(1, mov) * 2.6) / (1 + 0.45 * k);
}
});
};
const frame = (now: number) => {
raf = 0;
if (!alive) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
const c = cfg.current;
const reduced = mq.matches;
if (started && applied !== c.goal) {
const from = cur;
applied = c.goal;
if (reduced) {
cur = c.goal;
tw = null;
} else {
const dist = Math.abs(c.goal - from);
const dur = first ? Math.max(0.2, c.duration) : Math.min(1.6, Math.max(0.7, 0.7 + 0.2 * Math.log10(1 + dist)));
tw = { from, to: c.goal, t0: now, dur };
}
first = false;
}
if (tw) {
const t = (now - tw.t0) / (1000 * tw.dur);
if (t >= 1) {
cur = tw.to;
finish(tw, now);
tw = null;
} else {
const q = 1 - t;
cur = tw.from + (tw.to - tw.from) * (1 - q * q * q * q);
}
}
const av = Math.abs(cur);
let busy = tw !== null;
for (let k = 0; k < MAX_COLS; k++) {
const d = drums[k];
const strip = strips.current[k];
if (!d || !strip) continue;
if (reduced) {
d.e = 0;
d.ev = 0;
d.kickAt = 0;
}
if (d.kickAt && now >= d.kickAt) {
d.ev += d.kickV;
d.kickAt = 0;
}
if (d.kickAt) busy = true;
for (let s = 0; s < 2; s++) {
const h = dt / 2;
d.ev += (-140 * d.e - 11 * d.ev) * h;
d.e += d.ev * h;
}
if (Math.abs(d.e) < 0.0007 && Math.abs(d.ev) < 0.004) {
d.e = 0;
d.ev = 0;
} else busy = true;
const pt = drumAt(av, k);
const pd = pt + d.e;
const speed = Math.abs(pt - d.prev) / dt;
d.prev = pt;
const y = ((pd % 10) + 10) % 10;
strip.style.transform = "translate3d(0," + (-y * CELL).toFixed(4) + "em,0)";
const b = smooth(12, 72, speed) * 0.032;
strip.style.filter = b > 0.001 ? "blur(" + b.toFixed(4) + "em)" : "none";
d.heat += (Math.min(1, speed / 40) - d.heat) * (1 - Math.exp(-12 * dt));
if (d.heat < 0.01) d.heat = 0;
else busy = true;
const heat = heats.current[k];
if (heat) heat.style.opacity = d.heat.toFixed(3);
const at = k === 0 || Math.floor(av) >= Math.pow(10, k) ? 1 : 0;
if (reduced) d.a = at;
else d.a += (at - d.a) * (1 - Math.exp(-16 * dt));
if (Math.abs(at - d.a) < 0.003) d.a = at;
else busy = true;
const box = boxes.current[k];
if (box) {
box.style.width = (d.a * (COL_W + GAP)).toFixed(4) + "em";
box.style.opacity = d.a.toFixed(3);
}
const sep = seps.current[k];
if (sep) {
sep.style.width = (d.a * SEP_W).toFixed(4) + "em";
sep.style.opacity = d.a.toFixed(3);
}
}
const sg = signBox.current;
if (sg) {
const at = cur < 0 ? 1 : 0;
if (reduced) signA = at;
else signA += (at - signA) * (1 - Math.exp(-16 * dt));
if (Math.abs(at - signA) < 0.003) signA = at;
else busy = true;
sg.style.width = (signA * 0.42).toFixed(4) + "em";
sg.style.opacity = signA.toFixed(3);
}
if (busy) raf = requestAnimationFrame(frame);
};
const go = () => {
if (!alive || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
wake.current = go;
let io: IntersectionObserver | null = null;
const begin = () => {
started = true;
go();
};
if (typeof IntersectionObserver === "undefined") begin();
else {
io = new IntersectionObserver(
(entries) => {
if (entries.some((en) => en.isIntersecting)) {
begin();
io?.disconnect();
}
},
{ threshold: 0.6 },
);
io.observe(root);
}
mq.addEventListener("change", go);
return () => {
alive = false;
wake.current = null;
if (raf) cancelAnimationFrame(raf);
io?.disconnect();
mq.removeEventListener("change", go);
};
}, []);
const order = Array.from({ length: cols }, (_, i) => cols - 1 - i);
const tile: CSSProperties = {
position: "relative",
flex: "none",
width: COL_W + "em",
height: CELL + "em",
margin: "0 " + GAP / 2 + "em",
overflow: "hidden",
borderRadius: "0.16em",
background: "linear-gradient(180deg,#1b1d22 0%,#0e0f12 52%,#181a1e 100%)",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.09), inset 0 1px 0 rgba(255,255,255,0.14)",
color: "#f5f6f8",
fontWeight: 650,
letterSpacing: "-0.02em",
};
return (
<span
ref={rootRef}
className={"relative inline-flex font-black tabular-nums " + className}
style={{ fontSize: "var(--odo-fs, 2.25rem)", lineHeight: 1, fontVariantNumeric: "tabular-nums", ...style }}
>
<span className="sr-only" role="status" aria-live="polite">
{prefix + grouped(goal, separator) + suffix}
</span>
<span aria-hidden="true" style={{ display: "inline-flex", alignItems: "center" }}>
{prefix ? (
<span style={{ opacity: 0.6, fontSize: "0.62em", marginRight: "0.18em", fontWeight: 600 }}>{prefix}</span>
) : null}
<span ref={signBox} style={{ display: "flex", justifyContent: "center", overflow: "hidden", width: 0, opacity: 0 }}>
<span style={{ flex: "none", opacity: 0.7, fontWeight: 600 }}>-</span>
</span>
{order.map((k) => (
<Fragment key={k}>
<span
ref={(el) => {
boxes.current[k] = el;
}}
style={{
display: "flex",
justifyContent: "center",
flex: "none",
overflow: "hidden",
height: CELL + "em",
width: k === 0 ? COL_W + GAP + "em" : 0,
opacity: k === 0 ? 1 : 0,
}}
>
<span style={tile}>
<span
ref={(el) => {
strips.current[k] = el;
}}
style={{ position: "absolute", left: 0, top: 0, width: "100%", willChange: "transform" }}
>
{STRIP.map((d, i) => (
<span key={i} style={{ display: "block", height: CELL + "em", lineHeight: CELL + "em", textAlign: "center" }}>
{d}
</span>
))}
</span>
<span
ref={(el) => {
heats.current[k] = el;
}}
style={{
position: "absolute",
inset: 0,
opacity: 0,
background: "radial-gradient(120% 60% at 50% 100%," + rgba(acc, 0.4) + ",transparent 70%)",
}}
/>
<span
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(180deg,rgba(0,0,0,0.8) 0%,rgba(0,0,0,0) 27%,rgba(0,0,0,0) 73%,rgba(0,0,0,0.8) 100%),linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0) 42%)",
}}
/>
</span>
</span>
{k > 0 && k % 3 === 0 ? (
<span
ref={(el) => {
seps.current[k] = el;
}}
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
flex: "none",
overflow: "hidden",
height: CELL + "em",
paddingBottom: "0.2em",
width: 0,
opacity: 0,
}}
>
<span style={{ flex: "none", opacity: 0.5 }}>{separator}</span>
</span>
) : null}
</Fragment>
))}
{suffix ? (
<span
style={{
marginLeft: "0.1em",
fontSize: "0.78em",
color: acc,
textShadow: "0 0 0.6em " + rgba(acc, 0.55),
}}
>
{suffix}
</span>
) : null}
</span>
</span>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this text effect
A stat counter built like the real thing. Each digit is a drum, a strip of 0 to 9 behind a dark glass window with cylinder shading, and the number counts up from zero the first time it is 60 percent in view. One eased value drives the whole count; the units drum turns continuously while every higher drum only turns during the carry from the drum beside it, so the tens, hundreds and thousands advance in the right order instead of all spinning at once. Fast drums blur and pick up a warm glow from the accent colour, then each one settles with a small detent bounce, the low drums first. Change target later and it rolls the right way: up for a bigger number, down for a smaller one, over a time that grows with the distance. Columns slide open as the number gains digits, and thousands separators slide in with them. The prefix is dimmed and inherits the text colour, the suffix takes the accent. Size comes from the CSS variable --odo-fs, everything inside is in em, and the default stays at 2.25rem like the original. Props: target, duration, prefix and suffix as before, plus separator, accent (#rrggbb), className and style. The drums are dark, so it reads on light and dark pages alike. React only, no animation library, and one rAF loop that sleeps when every drum is at rest. Reach for a charting library instead if you need decimals or currency formats; this one rounds to whole numbers.
Curator’s note
Rebuilt in October from a number that ticked up into a real odometer: every digit is its own drum, and the fast ones blur and glow while they spin.
