Skip to content

Odometer Counter

A stat number that rolls up from zero to its target the moment it's in view.

Freeeditorialminimal
Category
Text Effects
Added
2026-08-21
Deps
1
Updated
2026-09-01
Preview
0+

Props

12400
1.6
Requires framer-motion
"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>
  );
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A row of stat counters that count up together the moment they scroll into view.

statscountergrid
Featured
NewPro

Metrics, deltas and sparklines that all count in together from one shared loop.

statscountersparkline
Featured
NewPro

Copy that resolves word by word as you scroll, mapped by real reading position rather than word index.

scrolltypereveal