southernmonsarysupply/components/count-up-stat.tsx

87 lines
2.0 KiB
TypeScript

"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<HTMLDivElement | null>(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 (
<div ref={ref} className="stat-item">
<span className="stat-value">{displayValue}</span>
<span className="stat-label">{label}</span>
</div>
);
}