"use client"; import {useInView, useMotionValue, useSpring, type SpringOptions, type UseInViewOptions} from "motion/react"; import * as React from "react"; import {cn} from "@/lib/utilities"; import styles from "./counting-number.module.css"; /** Props accepted by {@link CountingNumber}. */ export interface CountingNumberProps extends React.HTMLAttributes { /** Final numeric value to animate toward. @default undefined */ number: number; /** Initial numeric value used before the spring animation starts. @default 0 */ fromNumber?: number; /** Pads the integer portion so it matches the target value width. @default false */ padStart?: boolean; /** Delays the animation until the element enters the viewport. @default false */ inView?: boolean; /** Margin passed to the in-view observer when `inView` is enabled. @default "0px" */ inViewMargin?: UseInViewOptions["margin"]; /** Prevents the in-view animation from replaying after the first reveal. @default true */ inViewOnce?: boolean; /** Character used between the integer and decimal portions. @default "." */ decimalSeparator?: string; /** Spring physics applied to the counting animation. @default {stiffness: 90, damping: 50} */ transition?: SpringOptions; /** Number of decimal places rendered during the animation. @default 0 */ decimalPlaces?: number; } /** * Animates a number value with a spring and writes the formatted value into a span. * * @remarks * - Animated component using the `motion` library * - Renders a `` element * - Styling via CSS Modules with `--ac-*` custom properties * - Client-side only (`"use client"` directive) * * @example * ```tsx * * ``` * * @see {@link CountingNumberProps} for available props */ const CountingNumber = React.forwardRef( ( { number, fromNumber = 0, padStart = false, inView = false, inViewMargin = "0px", inViewOnce = true, decimalSeparator = ".", transition = {stiffness: 90, damping: 50}, decimalPlaces = 0, className, ...props }, ref, ) => { const localRef = React.useRef(null); React.useImperativeHandle(ref, () => localRef.current!, []); const numberStr = number.toString(); const decimals = numberStr.includes(".") ? (numberStr.split(".")[1]?.length ?? 0) : 0; const resolvedDecimalPlaces = typeof decimalPlaces === "number" ? decimalPlaces : decimals; const motionValue = useMotionValue(fromNumber); const springValue = useSpring(motionValue, transition); const inViewResult = useInView(localRef, { once: inViewOnce, margin: inViewMargin, }); const isInView = !inView || inViewResult; React.useEffect(() => { if (isInView) { motionValue.set(number); } }, [isInView, motionValue, number]); React.useEffect(() => { const unsubscribe = springValue.on("change", (latest) => { if (!localRef.current) { return; } let formatted = resolvedDecimalPlaces > 0 ? latest.toFixed(resolvedDecimalPlaces) : Math.round(latest).toString(); if (resolvedDecimalPlaces > 0) { formatted = formatted.replace(".", decimalSeparator); } if (padStart) { const finalIntegerLength = Math.floor(Math.abs(number)).toString().length; const [integerPart, fractionPart] = formatted.split(decimalSeparator); const paddedInteger = integerPart.padStart(finalIntegerLength, "0"); formatted = fractionPart ? `${paddedInteger}${decimalSeparator}${fractionPart}` : paddedInteger; } localRef.current.textContent = formatted; }); return () => { unsubscribe(); }; }, [decimalSeparator, number, padStart, resolvedDecimalPlaces, springValue]); const finalIntegerLength = Math.floor(Math.abs(number)).toString().length; const suffix = resolvedDecimalPlaces > 0 ? `${decimalSeparator}${"0".repeat(resolvedDecimalPlaces)}` : ""; const initialText = padStart ? `${"0".padStart(finalIntegerLength, "0")}${suffix}` : `0${suffix}`; return ( {initialText} ); }, ); CountingNumber.displayName = "CountingNumber"; export {CountingNumber};