Odometer Counter
A stat number that rolls up from zero to its target the moment it's in view.
- Category
- Text Effects
- Added
- 2026-08-21
- Deps
- 1
- Updated
- 2026-09-01
Props
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView } from "framer-motion";
export function OdometerCounter({
target = 12400,
duration = 1.6,
prefix = "",
suffix = "+",
}: {
target?: number;
duration?: number;
/** Printed before the number, unstyled. */
prefix?: string;
suffix?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.8 });
const [value, setValue] = useState(0);
useEffect(() => {
if (!isInView) return;
const start = performance.now();
let raf = 0;
function tick(now: number) {
const elapsed = (now - start) / 1000;
const t = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
setValue(Math.round(eased * target));
if (t < 1) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isInView, target, duration]);
return (
<span ref={ref} className="text-4xl font-black tabular-nums">
{prefix}
{value.toLocaleString()}
<span className="text-red-500">{suffix}</span>
</span>
);
}
About this text effect
Stat sections with a static number sitting on the page get scrolled past without registering. This counts up from zero the instant the element crosses 80 percent into the viewport, using useInView with once set to true so it fires exactly one time rather than re-triggering whenever the section scrolls back into frame. The animation is hand-rolled with requestAnimationFrame rather than a spring or tween library: elapsed time against a fixed duration, run through a cubic ease-out, so the count decelerates into its final value instead of ticking at a constant rate that looks broken. The number is formatted with toLocaleString for thousands separators and set to tabular-nums so digit width does not jitter the layout as it counts. Reach for this over a charting library's built-in counter when a landing page just needs one number to animate; it is the wrong tool if you need to re-run the count on demand, since once-triggered is baked into the useInView call rather than exposed as a prop.
Curator’s note
Cubic ease-out on the count, not linear — linear counting reads as a spinner glitching, the ease makes it feel like it's settling into place.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Animated Stat Grid
sectionsA row of stat counters that count up together the moment they scroll into view.
Kinetic Stat Board
sectionsMetrics, deltas and sparklines that all count in together from one shared loop.
Masked Scroll Highlight
text effectsCopy that resolves word by word as you scroll, mapped by real reading position rather than word index.