129 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|