"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(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 | 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 ( {prefix} {display} {suffix} ); }