Files
Webklar/src/components/CountUp.tsx

129 lines
3.3 KiB
TypeScript

import { useInView, useMotionValue, useSpring } from "motion/react";
import { useCallback, useEffect, useRef } from "react";
interface CountUpProps {
to: number;
from?: number;
direction?: "up" | "down";
delay?: number;
duration?: number;
className?: string;
startWhen?: boolean;
separator?: string;
suffix?: string;
prefix?: string;
padMinLength?: number;
onStart?: () => void;
onEnd?: () => void;
}
export default function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2,
className = "",
startWhen = true,
separator = "",
suffix = "",
prefix = "",
padMinLength,
onStart,
onEnd,
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? to : from);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, {
damping,
stiffness,
});
const isInView = useInView(containerRef, { once: true, margin: "0px" });
const getDecimalPlaces = (num: number) => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (decimals && parseInt(decimals, 10) !== 0) {
return decimals.length;
}
}
return 0;
};
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
const formatValue = useCallback(
(latest: number) => {
if (padMinLength != null) {
const n = Math.round(latest);
return n.toString().padStart(padMinLength, "0");
}
const hasDecimals = maxDecimals > 0;
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
};
let formattedNumber = Intl.NumberFormat("de-DE", options).format(latest);
if (separator) {
formattedNumber = formattedNumber.replace(/\s/g, separator);
}
return formattedNumber;
},
[maxDecimals, separator, padMinLength]
);
useEffect(() => {
if (ref.current) {
ref.current.textContent = formatValue(direction === "down" ? to : from);
}
}, [from, to, direction, formatValue]);
useEffect(() => {
if (isInView && startWhen) {
if (typeof onStart === "function") onStart();
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
if (typeof onEnd === "function") onEnd();
},
delay * 1000 + duration * 1000
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = formatValue(latest);
}
});
return () => unsubscribe();
}, [springValue, formatValue]);
return (
<span ref={containerRef} className={className}>
{prefix && <span>{prefix}</span>}
<span ref={ref} />
{suffix && <span>{suffix}</span>}
</span>
);
}