"use client"; import { useEffect, useRef, useState } from "react"; /** * Number that ticks up from 0 → `value` when scrolled into view. * Uses requestAnimationFrame only (no animation library). * * CUSTOMIZATION HOOK — CONTENT: value / prefix / suffix. */ export default function Counter({ value, duration = 1600, prefix = "", suffix = "", className = "", static: isStatic = false, }: { value: number; duration?: number; prefix?: string; suffix?: string; className?: string; /** Render the value as plain text (no tick animation). Use for years, dates, or any value that should not be perceived as "counting up". See LAYOUT_AUDIT #11. */ static?: boolean; }) { const ref = useRef(null); const [display, setDisplay] = useState(isStatic ? value : 0); useEffect(() => { // When `static` is set, the value is rendered as-is with no observer. if (isStatic) return; const el = ref.current; if (!el) return; const prefersReduced = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; // Hoisted to the effect scope so the cleanup below can cancel an // in-flight frame on unmount / dep change (the IO callback's own // return value is never used as a cleanup). let raf = 0; const io = new IntersectionObserver( ([entry]) => { if (!entry.isIntersecting) return; io.disconnect(); if (prefersReduced) { setDisplay(value); return; } const start = performance.now(); const tick = (now: number) => { const t = Math.min((now - start) / duration, 1); // easeOutExpo const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t); setDisplay(Math.round(eased * value)); if (t < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); }, { threshold: 0.4 } ); io.observe(el); return () => { io.disconnect(); cancelAnimationFrame(raf); }; }, [value, duration, isStatic]); return ( {prefix} {display} {suffix} ); }