"use client"; import { memo, useEffect, useId, useMemo, useRef, useState } from "react"; import type { ComponentProps, ReactNode, Ref } from "react"; import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform, type Transition, } from "motion/react"; import { cn } from "@/lib/utils"; export type Grouping = "western" | "indian"; export interface AnimatedCounterProps extends Omit< ComponentProps<"span">, | "children" | "prefix" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd" > { value: number; decimals?: number; duration?: number; padStart?: number; separator?: string; decimalSeparator?: string; grouping?: Grouping; prefix?: ReactNode; suffix?: ReactNode; gooey?: boolean; } const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const BAND = 10; const WHEEL = [...DIGITS, ...DIGITS, ...DIGITS]; const LINE_HEIGHT = 1.5; const MASK_GRADIENT = `linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.08) 6%, rgba(0,0,0,0.6) 13%, rgba(0,0,0,0.96) 20%, #000 28%, #000 72%, rgba(0,0,0,0.96) 80%, rgba(0,0,0,0.6) 87%, rgba(0,0,0,0.08) 94%, rgba(0,0,0,0) 100%)`; const LEAVE_TRANSITION: Transition = { duration: 0.18, ease: [0.22, 1, 0.36, 1], }; const INSTANT_TRANSITION: Transition = { duration: 0, }; const createSpring = (duration: number): Transition => ({ type: "spring", visualDuration: duration, bounce: 0.16, }); const SIZER_NODES = DIGITS.map((digit) => ( {digit} )); const STACK_NODES = WHEEL.map((digit, index) => ( {digit} )); const REGEX_WESTERN = /\B(?=(\d{3})+(?!\d))/g; const REGEX_INDIAN = /\B(?=(\d{2})+(?!\d))/g; function formatInteger(whole: string, separator: string, grouping: Grouping) { if (!separator) return whole; if (grouping !== "indian") return whole.replace(REGEX_WESTERN, separator); const head = whole.slice(0, -3); if (!head) return whole; return `${head.replace(REGEX_INDIAN, separator)}${separator}${whole.slice(-3)}`; } interface Measurement { amount: number; scaled: number; places: number; duration: number; width: number; } function computeMetrics( value: number, decimals: number, padStart: number, duration: number, ): Measurement { const amount = Number.isFinite(value) ? value : 0; const places = Math.min(15, Math.max(0, Math.trunc(decimals))); const pad = Math.min(24, Math.max(1, Math.trunc(padStart))); const scaled = Math.min( Number.MAX_SAFE_INTEGER, Math.round(Math.abs(amount) * 10 ** places), ); return { amount, scaled, places, duration: Math.min(60, Math.max(0.01, duration)), width: Math.max(String(scaled).length, places + pad), }; } function stringifyValue( metrics: Measurement, separator: string, decimalSeparator: string, grouping: Grouping, ) { const raw = String(metrics.scaled).padStart(metrics.width, "0"); const whole = formatInteger( raw.slice(0, raw.length - metrics.places) || "0", separator, grouping, ); return metrics.places ? `${whole}${decimalSeparator}${raw.slice(raw.length - metrics.places)}` : whole; } type CounterCell = | { type: "digit"; key: number; digit: number } | { type: "mark"; key: string; char: string }; function buildCells(formatted: string, width: number): CounterCell[] { const result: CounterCell[] = []; let place = 0; let markIndex = 0; for (const char of formatted) { if (char >= "0" && char <= "9") { markIndex = 0; result.push({ type: "digit", key: width - place++, digit: Number(char), }); } else { result.push({ type: "mark", key: `mark-${width - place}-${markIndex++}`, char, }); } } return result; } function useContinuousWheel( initialDigit: number, targetDigit: number, direction: number, duration: number, reduced: boolean, ) { const pos = useMotionValue(BAND + initialDigit); const headingRef = useRef(direction); useEffect(() => { headingRef.current = direction; }, [direction]); useEffect(() => { if (reduced) { pos.set(BAND + targetDigit); return; } const current = pos.get(); const currentFace = ((Math.round(current) % 10) + 10) % 10; let diff = targetDigit - currentFace; if (headingRef.current > 0 && diff <= 0) diff += 10; if (headingRef.current < 0 && diff >= 0) diff -= 10; const nextTarget = current + diff; const animation = animate(pos, nextTarget, createSpring(duration)); return () => { animation.stop(); pos.set(BAND + targetDigit); }; }, [targetDigit, duration, reduced, pos]); return useTransform(pos, (p) => `${(-p * 100) / WHEEL.length}%`); } interface DigitSlotProps { reduced: boolean; dep: number; transition: Transition; filterId?: string; } const DigitColumn = memo(function DigitColumn({ digit, from, direction, duration, slot, ref, }: { digit: number; from: number; direction: number; duration: number; slot: DigitSlotProps; ref?: Ref; }) { const y = useContinuousWheel(from, digit, direction, duration, slot.reduced); return ( {SIZER_NODES} {STACK_NODES} ); }); function GooeyDef({ id }: { id: string }) { return ( ); } export function AnimatedCounter({ value, decimals = 0, duration = 0.6, padStart = 1, separator = ",", decimalSeparator = ".", grouping = "western", prefix, suffix, gooey = true, className, ...props }: AnimatedCounterProps) { const generatedId = useId().replace(/:/g, "_"); const filterId = `gooey_${generatedId}`; const reduced = useReducedMotion() ?? false; const metrics = computeMetrics(value, decimals, padStart, duration); const formatted = stringifyValue(metrics, separator, decimalSeparator, grouping); const cells = buildCells(formatted, metrics.width); const isNegative = metrics.amount < 0 && metrics.scaled > 0; const [previous, setPrevious] = useState(metrics.amount); const [direction, setDirection] = useState(1); if (previous !== metrics.amount) { setDirection(metrics.amount >= previous ? 1 : -1); setPrevious(metrics.amount); } const [seedFaces] = useState(() => { const initial: Record = {}; for (const cell of cells) { if (cell.type === "digit") initial[cell.key] = cell.digit; } return initial; }); const springTransition = useMemo( () => (reduced ? INSTANT_TRANSITION : createSpring(metrics.duration)), [reduced, metrics.duration], ); const slotProps: DigitSlotProps = { reduced, dep: formatted.length, transition: springTransition, filterId: gooey && !reduced ? filterId : undefined, }; return ( {gooey && !reduced && } {prefix != null && ( {prefix} )} {isNegative ? "-" : ""} {formatted} {isNegative && ( - )} {cells.map((cell) => cell.type === "digit" ? ( ) : ( {cell.char} ), )} {suffix != null && ( {suffix} )} ); } export default AnimatedCounter;