'use client'; import { useEffect, useRef, useState } from 'react'; import { fmt } from '@/lib/format'; /** * Tweens between values ONLY when `value` changes after mount (a real update arrived). No idle motion. * The first render prints the SSR value verbatim so there is no hydration flicker. */ export function AnimatedNumber({ value, digits = 1, duration = 600, className = '', style, ...rest }: { value: number | null | undefined; digits?: number; duration?: number; className?: string; style?: React.CSSProperties } & React.AriaAttributes) { const [display, setDisplay] = useState(value); const fromRef = useRef(value); const rafRef = useRef(null); useEffect(() => { const from = fromRef.current; if (value == null || from == null || value === from || typeof window === 'undefined' || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { fromRef.current = value; setDisplay(value); return; } const start = performance.now(); const f = from; const step = (t: number) => { const p = Math.min(1, (t - start) / duration); const e = 1 - (1 - p) ** 3; // ease-out cubic setDisplay(f + (value - f) * e); if (p < 1) rafRef.current = requestAnimationFrame(step); else fromRef.current = value; }; rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); fromRef.current = value; }; }, [value, duration]); return ( {fmt(display, digits)} ); }