import { memo, useEffect, useCallback, useRef, useState, CSSProperties, } from "react"; export interface AnimatedCounterProps { value?: number; incrementColor?: string; decrementColor?: string; includeDecimals?: boolean; decimalPrecision?: number; padNumber?: number; showColorsWhenValueChanges?: boolean; includeCommas?: boolean; containerStyles?: CSSProperties; digitStyles?: CSSProperties; className?: string; } export interface NumberColumnProps { digit: string; delta: string | null; incrementColor: string; decrementColor: string; digitStyles: CSSProperties; showColorsWhenValueChanges?: boolean; } export interface DecimalColumnProps { isComma: boolean; digitStyles: CSSProperties; } // Decimal element component const DecimalColumn = ({ isComma, digitStyles }: DecimalColumnProps) => ( {isComma ? "," : "."} ); // Individual number element component const NumberColumn = memo( ({ digit, delta, incrementColor, decrementColor, digitStyles, showColorsWhenValueChanges, }: NumberColumnProps) => { const [position, setPosition] = useState(0); const [animationClass, setAnimationClass] = useState(null); const [movementType, setMovementType] = useState< "increment" | "decrement" | null >(null); const currentDigit = +digit; const previousDigit = usePrevious(+currentDigit); const columnContainer = useRef(null); const setColumnToNumber = useCallback( (number: string) => { if (columnContainer.current) { setPosition( columnContainer.current.clientHeight * parseInt(number, 10), ); } }, [columnContainer.current?.clientHeight], ); useEffect(() => { setAnimationClass(previousDigit !== currentDigit ? delta : ""); if (!showColorsWhenValueChanges) return; if (delta === "animate-moveUp") { setMovementType("increment"); } else if (delta === "animate-moveDown") { setMovementType("decrement"); } }, [digit, delta, previousDigit, currentDigit]); // reset movementType after 300ms useEffect(() => { setTimeout(() => { setMovementType(null); }, 300); }, [movementType]); useEffect(() => { setColumnToNumber(digit); }, [digit, setColumnToNumber]); if (digit === "-") { return {digit}; } return ( {[9, 8, 7, 6, 5, 4, 3, 2, 1, 0].reverse().map((num) => ( {num} ))} 0 ); }, (prevProps, nextProps) => prevProps.digit === nextProps.digit && prevProps.delta === nextProps.delta, ); // Main component const AnimatedCounter = ({ value = 0, incrementColor = "#32cd32", decrementColor = "#fe6862", includeDecimals = true, decimalPrecision = 2, includeCommas = false, containerStyles = {}, digitStyles = {}, padNumber = 0, className = "", showColorsWhenValueChanges = true, }: AnimatedCounterProps) => { const numArray = formatForDisplay( Math.abs(value), includeDecimals, decimalPrecision, includeCommas, padNumber, ); const previousNumber = usePrevious(value); const isNegative = value < 0; let delta: string | null = null; if (previousNumber !== null) { if (value > previousNumber) { delta = "animate-moveUp"; // Tailwind class for increase } else if (value < previousNumber) { delta = "animate-moveDown"; // Tailwind class for decrease } } return ( {/* If number is negative, render '-' feedback */} {isNegative && ( )} {/* Format integer to NumberColumn components */} {numArray.map((number: string, index: number) => number === "." || number === "," ? ( ) : ( ), )} ); }; const formatForDisplay = ( number: number, includeDecimals: boolean, decimalPrecision: number, includeCommas: boolean, padTo: number = 0, ): string[] => { const decimalCount = includeDecimals ? decimalPrecision : 0; const parsedNumber = parseFloat(`${Math.max(number, 0)}`).toFixed( decimalCount, ); const numberToFormat = includeCommas ? parseFloat(parsedNumber).toLocaleString("en-US", { minimumFractionDigits: includeDecimals ? decimalPrecision : 0, }) : parsedNumber; return numberToFormat.padStart(padTo, "0").split(""); }; // Hook used to track previous value of primary number state in AnimatedCounter & individual digits in NumberColumn const usePrevious = (value: number | null) => { const ref = useRef(null); useEffect(() => { ref.current = value; }, [value]); return ref.current; }; export default AnimatedCounter;