100 lines
2.8 KiB
TypeScript
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>
|
|
);
|
|
}
|