"use client"; import { animate, useInView, useReducedMotion } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; type CountUpStatProps = { value: string; label: string; }; type ParsedValue = { prefix: string; target: number; suffix: string; }; const smoothEase = [0.22, 1, 0.36, 1] as const; function parseValue(value: string): ParsedValue | null { const match = value.match(/^([^0-9]*)([\d,]+)(.*)$/); if (!match) { return null; } const [, prefix, rawNumber, suffix] = match; const target = Number.parseInt(rawNumber.replace(/,/g, ""), 10); if (Number.isNaN(target)) { return null; } return { prefix, target, suffix }; } function formatValue(parsed: ParsedValue, current: number) { return `${parsed.prefix}${new Intl.NumberFormat("en-US").format(current)}${parsed.suffix}`; } export function CountUpStat({ value, label }: CountUpStatProps) { const ref = useRef(null); const isInView = useInView(ref, { once: true, amount: 0.45 }); const shouldReduceMotion = useReducedMotion(); const parsed = useMemo(() => parseValue(value), [value]); const [displayValue, setDisplayValue] = useState(() => parsed ? formatValue(parsed, 0) : value, ); const hasAnimated = useRef(false); useEffect(() => { if (!parsed) { setDisplayValue(value); return; } if (!isInView || hasAnimated.current) { return; } hasAnimated.current = true; if (shouldReduceMotion) { setDisplayValue(formatValue(parsed, parsed.target)); return; } const controls = animate(0, parsed.target, { duration: 1.4, ease: smoothEase, onUpdate(latest) { setDisplayValue(formatValue(parsed, Math.round(latest))); }, }); return () => { controls.stop(); }; }, [isInView, parsed, shouldReduceMotion, value]); return (
{displayValue} {label}
); }