'use client'; import * as React from 'react'; import { type SpringOptions, useMotionValue, useSpring } from 'motion/react'; import { useIsInView, type UseIsInViewOptions } from '@/hooks/use-is-in-view'; type CountingNumberProps = Omit, 'children'> & { number: number; fromNumber?: number; padStart?: boolean; decimalSeparator?: string; decimalPlaces?: number; transition?: SpringOptions; delay?: number; initiallyStable?: boolean; thousandSeparator?: string; } & UseIsInViewOptions; function CountingNumber({ ref, number, fromNumber = 0, padStart = false, inView = false, inViewMargin = '0px', inViewOnce = true, decimalSeparator = '.', transition = { stiffness: 200, damping: 30 }, decimalPlaces = 0, delay = 0, initiallyStable = false, thousandSeparator = '', ...props }: CountingNumberProps) { const { ref: localRef, isInView } = useIsInView( ref as React.Ref, { inView, inViewOnce, inViewMargin, }, ); const numberStr = number.toString(); const decimals = typeof decimalPlaces === 'number' ? decimalPlaces : numberStr.includes('.') ? (numberStr.split('.')[1]?.length ?? 0) : 0; const motionVal = useMotionValue(initiallyStable ? number : fromNumber); const springVal = useSpring(motionVal, transition); React.useEffect(() => { const timeoutId = setTimeout(() => { if (isInView) motionVal.set(number); }, delay); return () => clearTimeout(timeoutId); }, [isInView, number, motionVal, delay]); 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; } if (thousandSeparator) { const [intPart, fracPart] = formatted.split(decimalSeparator); const regex = /\B(?=(\d{3})+(?!\d))/g; const separatedInt = intPart.replace(regex, thousandSeparator); formatted = fracPart ? `${separatedInt}${decimalSeparator}${fracPart}` : separatedInt; } localRef.current.textContent = formatted; } }); return () => unsubscribe(); }, [ springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator, ]); const finalIntLength = Math.floor(Math.abs(number)).toString().length; const formatValue = (val: number) => { let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString(); if (decimals > 0) out = out.replace('.', decimalSeparator); if (padStart) { const [intPart, fracPart] = out.split(decimalSeparator); const paddedInt = (intPart ?? '').padStart(finalIntLength, '0'); out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt; } if (thousandSeparator) { const [intPart, fracPart] = out.split(decimalSeparator); const regex = /\B(?=(\d{3})+(?!\d))/g; const separatedInt = intPart.replace(regex, thousandSeparator); out = fracPart ? `${separatedInt}${decimalSeparator}${fracPart}` : separatedInt; } return out; }; const zeroText = padStart ? '0'.padStart(finalIntLength, '0') + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '') : '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : ''); const initialText = initiallyStable ? formatValue(number) : zeroText; return ( {initialText} ); } export { CountingNumber, type CountingNumberProps };