"use client"; import { animate, useInView, useReducedMotion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; type Props = { /** The number to land on. */ value: number; /** Where the count starts. A year should roll a few digits, not run up * from zero, so the caller sets its own starting point. */ from?: number; /** Seconds. */ duration?: number; className?: string; }; /** * Counts up to a number once, the first time it scrolls into view. * * The animated digits are hidden from assistive tech and the real value is * exposed alongside them, so a screen reader announces "3" rather than every * intermediate frame. Honours `prefers-reduced-motion`: the final value is * rendered immediately. */ export function CountUp({ value, from = 0, duration = 1.4, className = "", }: Props) { const ref = useRef(null); const inView = useInView(ref, { once: true, margin: "-80px" }); const reduced = useReducedMotion(); const [count, setCount] = useState(from); useEffect(() => { if (reduced || !inView) return; const controls = animate(from, value, { duration, ease: [0.22, 1, 0.36, 1], onUpdate: (latest) => setCount(Math.round(latest)), }); return () => controls.stop(); }, [inView, reduced, from, value, duration]); // Derived rather than stored: with reduced motion there is no animation to // run, so the final value is simply what renders. const display = reduced ? value : count; return ( {display} {value} ); }