"use client"; import { useEffect, useRef, useState } from "react"; interface CountUpProps { /** Target number to count up to */ to: number; /** String appended after the number (e.g. "%", " MB", "+") */ suffix?: string; /** String prepended before the number */ prefix?: string; /** Animation duration in ms */ duration?: number; /** Decimal places to show */ decimals?: number; } export default function CountUp({ to, suffix = "", prefix = "", duration = 1800, decimals = 0, }: CountUpProps) { const ref = useRef(null); const [current, setCurrent] = useState(0); const [started, setStarted] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !started) { setStarted(true); observer.disconnect(); } }, { threshold: 0.5 }, ); observer.observe(el); return () => observer.disconnect(); }, [started]); useEffect(() => { if (!started) return; // Skip animation if user prefers reduced motion if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setCurrent(to); return; } const startTime = performance.now(); let raf: number; function tick(now: number) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-out quart — fast start, smooth finish const eased = 1 - Math.pow(1 - progress, 4); setCurrent(eased * to); if (progress < 1) raf = requestAnimationFrame(tick); } raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [started, to, duration]); const display = decimals > 0 ? current.toFixed(decimals) : Math.floor(current).toString(); return ( {prefix} {display} {suffix} ); }