"use client"; /** * CountUp — animates a number from 0 to its target when it scrolls into view. * Uses GSAP + ScrollTrigger, formats with optional prefix/suffix/decimals and * tabular figures. Under reduced-motion it renders the final value immediately. * The full value is also written to the DOM on mount so it is correct even if * JS/animation never runs (accessible + SSR-safe). */ import { useEffect, useRef } from "react"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger); type Props = { value: number; prefix?: string; suffix?: string; decimals?: number; className?: string; }; export default function CountUp({ value, prefix = "", suffix = "", decimals = 0, className = "", }: Props) { const ref = useRef(null); const format = (n: number) => `${prefix}${n.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, })}${suffix}`; useEffect(() => { const el = ref.current; if (!el) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = format(value); return; } const obj = { n: 0 }; el.textContent = format(0); const ctx = gsap.context(() => { gsap.to(obj, { n: value, duration: 2, ease: "power2.out", scrollTrigger: { trigger: el, start: "top 85%", once: true }, onUpdate: () => { el.textContent = format(obj.n); }, }); }, el); return () => ctx.revert(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, prefix, suffix, decimals]); // Render full value at SSR/first paint; the effect resets to 0 then animates. return ( {format(value)} ); }