import { useEffect, useRef, useState, type CSSProperties, } from 'react'; import { SPRINGS, prefersReducedMotion } from './utils.js'; export interface NumberFlowProps { /** Target value to animate to. */ value: number; /** Tween duration in ms. Default 700. */ duration?: number; /** Decimal places. Default 0. */ decimals?: number; /** Optional formatter (overrides `decimals` + locale). */ format?: (n: number) => string; /** Locale for default formatter. Default 'en-US'. */ locale?: string; /** Prefix rendered before the number (e.g. '$'). */ prefix?: string; /** Suffix rendered after the number (e.g. '%'). */ suffix?: string; /** Bypass animation. */ disableMotion?: boolean; className?: string; style?: CSSProperties; } /** * `` — smoothly tweens a number from its previous value to * the new one. Counts up, counts down, and respects * `prefers-reduced-motion` (snaps to final value). * * Pure RAF — no dependencies. Cancels in-flight tweens when `value` * changes mid-animation so the latest target always wins. */ export function NumberFlow({ value, duration = 700, decimals = 0, format, locale = 'en-US', prefix, suffix, disableMotion, className, style, }: NumberFlowProps) { const [display, setDisplay] = useState(value); const fromRef = useRef(value); const rafRef = useRef(null); const reduced = disableMotion ?? prefersReducedMotion(); useEffect(() => { if (reduced) { fromRef.current = value; setDisplay(value); return; } const from = fromRef.current; const start = performance.now(); const tween = (now: number) => { const t = Math.min(1, (now - start) / duration); // Apply a snappy ease-out so the number doesn't feel linear. const eased = 1 - Math.pow(1 - t, 3); const current = from + (value - from) * eased; setDisplay(current); if (t < 1) { rafRef.current = requestAnimationFrame(tween); } else { fromRef.current = value; rafRef.current = null; } }; rafRef.current = requestAnimationFrame(tween); return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; }, [value, duration, reduced]); const formatted = format ? format(display) : new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(display); return ( {prefix} {formatted} {suffix} ); }