// AnimatedNumber — counts UP to the target value when scrolled into view. // Mounts an IntersectionObserver, runs ONE rAF loop for ~700ms, then stops. // No re-trigger. // // Use for the stats row on the landing — gives the impression of // "live numbers" without actually polling anything. // // Respects prefers-reduced-motion (jumps straight to the final value). // // IT RENDERS THE FINAL VALUE, AND THAT IS NOT A DETAIL. The first version // initialised its state to 0, so every statically rendered page SHIPPED a zero: // the landing's own stats row went out as "0 … 0 … 0% … 0" in the markup, and // that is what a crawler, an answer engine and a reader with JavaScript off all // saw. A number that only exists after hydration is not a number on the page. // // So the initial state is the TARGET — which also means the server's markup and // the first client render agree, so there is no hydration mismatch — and the // count-up starts from zero in an effect, after mount, when the element scrolls // into view. The animation is decoration layered onto a correct page, rather // than the only way to see the value. import { useEffect, useRef, useState, type ReactNode } from 'react' interface AnimatedNumberProps { /** Target value. */ readonly value: number /** Formatting — `'integer'` (default), `'percent'`, or a custom * formatter. */ readonly format?: 'integer' | 'percent' | ((n: number) => string) /** Animation duration in ms. Default 800. */ readonly duration?: number } const fmt = (n: number, format: AnimatedNumberProps['format']): string => { if (typeof format === 'function') return format(n) if (format === 'percent') return `${n.toFixed(0)}%` return Math.round(n).toLocaleString('en-US') } export const AnimatedNumber = ({ value, format = 'integer', duration = 800, }: AnimatedNumberProps): ReactNode => { const ref = useRef(null) // Seeded with the final value — see the header. Never with 0. const [current, setCurrent] = useState(value) useEffect(() => { // No observer (SSR-ish runtimes, old browsers) and reduced motion both keep // the value exactly as rendered. Nothing to do in either case. if (typeof IntersectionObserver === 'undefined') return const prefersReduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches if (prefersReduced) return const node = ref.current if (!node) return const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { observer.disconnect() // Drop to zero HERE, not at render — this is the first moment the // animation is actually going to run. setCurrent(0) const start = performance.now() const tick = (now: number): void => { const t = Math.min((now - start) / duration, 1) // ease-out cubic — fast at first, gentle stop const eased = 1 - Math.pow(1 - t, 3) setCurrent(value * eased) if (t < 1) requestAnimationFrame(tick) else setCurrent(value) } requestAnimationFrame(tick) return } } }, { threshold: 0.4 }) observer.observe(node) return () => observer.disconnect() }, [value, duration]) return ( {fmt(current, format)} ) }