107 lines
2.8 KiB
TypeScript
107 lines
2.8 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import {
|
|
type SpringOptions,
|
|
type UseInViewOptions,
|
|
useInView,
|
|
useMotionValue,
|
|
useSpring,
|
|
} from 'motion/react';
|
|
|
|
type CountingNumberProps = React.ComponentProps<'span'> & {
|
|
number: number;
|
|
fromNumber?: number;
|
|
padStart?: boolean;
|
|
inView?: boolean;
|
|
inViewMargin?: UseInViewOptions['margin'];
|
|
inViewOnce?: boolean;
|
|
decimalSeparator?: string;
|
|
transition?: SpringOptions;
|
|
decimalPlaces?: number;
|
|
};
|
|
|
|
function CountingNumber({
|
|
ref,
|
|
number,
|
|
fromNumber = 0,
|
|
padStart = false,
|
|
inView = false,
|
|
inViewMargin = '0px',
|
|
inViewOnce = true,
|
|
decimalSeparator = '.',
|
|
transition = { stiffness: 90, damping: 50 },
|
|
decimalPlaces = 0,
|
|
className,
|
|
...props
|
|
}: CountingNumberProps) {
|
|
const localRef = React.useRef<HTMLSpanElement>(null);
|
|
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
|
|
|
const numberStr = number.toString();
|
|
const decimals =
|
|
typeof decimalPlaces === 'number'
|
|
? decimalPlaces
|
|
: numberStr.includes('.')
|
|
? (numberStr.split('.')[1]?.length ?? 0)
|
|
: 0;
|
|
|
|
const motionVal = useMotionValue(fromNumber);
|
|
const springVal = useSpring(motionVal, transition);
|
|
const inViewResult = useInView(localRef, {
|
|
once: inViewOnce,
|
|
margin: inViewMargin,
|
|
});
|
|
const isInView = !inView || inViewResult;
|
|
|
|
React.useEffect(() => {
|
|
if (isInView) motionVal.set(number);
|
|
}, [isInView, number, motionVal]);
|
|
|
|
React.useEffect(() => {
|
|
const unsubscribe = springVal.on('change', (latest) => {
|
|
if (localRef.current) {
|
|
let formatted =
|
|
decimals > 0
|
|
? latest.toFixed(decimals)
|
|
: Math.round(latest).toString();
|
|
|
|
if (decimals > 0) {
|
|
formatted = formatted.replace('.', decimalSeparator);
|
|
}
|
|
|
|
if (padStart) {
|
|
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
|
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
|
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
|
|
formatted = fracPart
|
|
? `${paddedInt}${decimalSeparator}${fracPart}`
|
|
: paddedInt;
|
|
}
|
|
|
|
localRef.current.textContent = formatted;
|
|
}
|
|
});
|
|
return () => unsubscribe();
|
|
}, [springVal, decimals, padStart, number, decimalSeparator]);
|
|
|
|
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
|
const initialText = padStart
|
|
? '0'.padStart(finalIntLength, '0') +
|
|
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
|
|
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
|
|
|
|
return (
|
|
<span
|
|
ref={localRef}
|
|
data-slot="counting-number"
|
|
className={className}
|
|
{...props}
|
|
>
|
|
{initialText}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export { CountingNumber, type CountingNumberProps };
|