87 lines
2.0 KiB
TypeScript
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>
|
|
);
|
|
}
|