Use it in Next.js
hooks/use-count-up.ts
"use client";
import { useEffect, useRef, useState } from "react";
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
/**
* Counts from the value on screen to `to`. Starts when the element scrolls into view (once),
* jumps straight to the end for people who ask for reduced motion, and counts from the
* last shown value when `to` changes (a live number, a week/month switch).
*/
export function useCountUp<T extends Element>(to: number, { ms = 1200, from = 0 } = {}) {
const ref = useRef<T | null>(null);
const shown = useRef(from);
const [value, setValue] = useState(from);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return setVisible(true);
const io = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
setVisible(true);
io.disconnect();
}
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!visible) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
shown.current = to;
setValue(to);
return;
}
const start = shown.current;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const p = Math.min(1, (now - t0) / ms);
shown.current = start + (to - start) * easeOutQuart(p);
setValue(shown.current);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [to, ms, visible]);
return [ref, value] as const;
}
components/count-up.tsx
"use client";
import { useMemo } from "react";
import { useCountUp } from "@/hooks/use-count-up";
type Props = {
to: number;
/** Intl options: { style: "currency", currency: "EUR" }, { style: "percent" }, { maximumFractionDigits: 1 } */
format?: Intl.NumberFormatOptions;
locale?: string;
ms?: number;
className?: string;
};
export function CountUp({ to, format, locale = "en-US", ms, className }: Props) {
const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);
const [ref, value] = useCountUp<HTMLSpanElement>(to, { ms });
return (
<span ref={ref} className={className}>
{/* Screen readers and crawlers get the final number; the moving one is decoration. */}
<span className="sr-only">{fmt.format(to)}</span>
<span aria-hidden="true" className="tabular-nums">
{fmt.format(value)}
</span>
</span>
);
}
app/stats/page.tsx (a Server Component page)
import { CountUp } from "@/components/count-up";
// A Server Component page can render CountUp: the "use client" line lives in its own file.
export default function Stats() {
return (
<section className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border p-6">
<p className="text-sm text-neutral-500">Revenue</p>
<CountUp to={48920} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} className="text-4xl font-bold" />
</div>
<div className="rounded-2xl border p-6">
<p className="text-sm text-neutral-500">Orders</p>
<CountUp to={1284} className="text-4xl font-bold" />
</div>
<div className="rounded-2xl border p-6">
<p className="text-sm text-neutral-500">Conversion</p>
<CountUp to={0.0382} format={{ style: "percent", maximumFractionDigits: 2 }} className="text-4xl font-bold" />
</div>
</section>
);
}