agency-web/app/components/CountUp.tsx

100 lines
2.8 KiB
TypeScript

"use client";
/**
* Animated number that counts up when it enters the viewport.
* - The FINAL value is the reliable resting state: it is rendered on the
* server / before JS hydrates and under prefers-reduced-motion, so the number
* NEVER persists at 0 (no "$0M+" looking-broken state, even with no JS).
* - When motion is allowed and the element is below the fold, the animation
* resets to 0 once and counts up as it scrolls into view. If the element is
* already in (or above) the viewport on mount, it animates immediately.
* - Motion `animate()` drives a raw value; we format with prefix/suffix/decimals.
* - Real number text stays in the DOM for SEO / screen readers.
*/
import { useEffect, useRef, useState } from "react";
import { animate, useReducedMotion } from "motion/react";
import { EASE_OUT } from "./motion";
export default function CountUp({
to,
prefix = "",
suffix = "",
decimals = 0,
duration = 1.8,
}: {
to: number;
prefix?: string;
suffix?: string;
decimals?: number;
duration?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
// Resting state = the FINAL value. SSR + no-JS + reduced-motion all show this.
const [val, setVal] = useState(to);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Reduced motion: leave the final value in place, never animate.
if (reduce) {
setVal(to);
return;
}
let controls: ReturnType<typeof animate> | null = null;
let started = false;
const run = () => {
if (started) return;
started = true;
// Reset to 0 only at the moment we start, so the count-up is visible —
// but we never SIT at 0 (the value was the final number until now).
setVal(0);
controls = animate(0, to, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setVal(v),
});
};
// Trigger on mount if already in or above the viewport (handles refresh
// mid-page, anchor jumps, and SSR-then-hydrate where the section is visible).
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (rect.top < vh * 0.85) {
run();
return () => controls?.stop();
}
// Otherwise wait until it scrolls into view.
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
run();
io.disconnect();
}
},
{ rootMargin: "0px 0px -15% 0px" }
);
io.observe(el);
return () => {
io.disconnect();
controls?.stop();
};
}, [to, duration, reduce]);
const display = val.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return (
<span ref={ref}>
{prefix}
{display}
{suffix}
</span>
);
}