"use client"; import { useEffect, useRef, useState } from "react"; type Props = { to: number; prefix?: string; suffix?: string; decimals?: number; duration?: number; }; /** * Count-up that fires once when scrolled into view. Renders the final * value immediately under reduced motion (and as SSR fallback) so the * real number is always present for assistive tech and no-JS. */ export default function CountUp({ to, prefix = "", suffix = "", decimals = 0, duration = 1600, }: Props) { const ref = useRef(null); const [val, setVal] = useState(to); const done = useRef(false); useEffect(() => { const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) { setVal(to); return; } setVal(0); const node = ref.current; if (!node) return; const io = new IntersectionObserver( ([e]) => { if (!e.isIntersecting || done.current) return; done.current = true; const start = performance.now(); const tick = (now: number) => { const p = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); setVal(to * eased); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); io.disconnect(); }, { threshold: 0.4 } ); io.observe(node); return () => io.disconnect(); }, [to, duration]); return ( {prefix} {val.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, })} {suffix} ); }