1"use client";2
3import React, { useEffect, useRef, useState } from "react";4import {5 AnimatePresence,6 animate,7 motion,8 useInView,9 useMotionValue,10 useReducedMotion,11 useTransform,12 useVelocity,13} from "motion/react";14import { cn } from "@/lib/utils";15
16const sizeMap = {17 sm: "1.5rem",18 md: "2rem",19 lg: "3rem",20 xl: "4rem",21 "2xl": "5rem",22} as const;23
24export type AnimatedSize = keyof typeof sizeMap;25
26export type AnimatedNumberProps = {27 28 number: number | string;29 prefix?: string;30 suffix?: string;31 label?: string;32 size?: AnimatedSize;33 locale?: string;34 formatOptions?: Intl.NumberFormatOptions;35 36 trend?: boolean;37 38 live?: boolean;39 className?: string;40 digitClassName?: string;41 42 durationPerDigit?: number;43 44 blur?: boolean;45 46 direction?: "up" | "down";47 48 easing?: [number, number, number, number];49};50
51const CELL = 1.1; 52
53function Reel({54 digit,55 delay,56 duration,57 started,58 reduce,59 blur,60}: {61 digit: number;62 delay: number;63 duration: number;64 started: boolean;65 reduce: boolean;66 blur: boolean;67}) {68 69 70 const pos = useMotionValue(0);71 const y = useTransform(pos, (p) => `${-(((p % 10) + 10) % 10) * CELL}em`);72 const velocity = useVelocity(pos);73 const filter = useTransform(velocity, (v) =>74 blur && !reduce ? `blur(${Math.min(Math.abs(v) * 0.09, 2.4).toFixed(2)}px)` : "none",75 );76 const first = useRef(true);77
78 useEffect(() => {79 if (!started) return;80 const current = pos.get();81 const from = ((current % 10) + 10) % 10;82 let delta = digit - from;83 84 if (first.current && !reduce) delta += 10;85 else if (delta > 5) delta -= 10;86 else if (delta < -5) delta += 10;87 first.current = false;88 if (reduce) {89 pos.jump(current + delta);90 return;91 }92 const controls = animate(pos, current + delta, {93 type: "spring",94 bounce: 0.15,95 duration,96 delay,97 });98 return () => controls.stop();99 }, [digit, started, reduce, delay, duration, pos]);100
101 102 return (103 <span104 className="relative inline-block overflow-hidden"105 style={{106 height: `${CELL}em`,107 maskImage: "linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent)",108 WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent)",109 }}110 >111 {}112 <span className="invisible block" style={{ lineHeight: `${CELL}em` }}>113 0114 </span>115 <motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" style={{ y, filter }}>116 {[...Array(11)].map((_, i) => (117 <span key={i} className="block" style={{ height: `${CELL}em`, lineHeight: `${CELL}em` }}>118 {i % 10}119 </span>120 ))}121 </motion.span>122 </span>123 );124}125
126export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({127 number,128 prefix = "",129 suffix = "",130 label,131 size = "xl",132 locale,133 formatOptions,134 trend = true,135 live = false,136 className,137 digitClassName,138 durationPerDigit = 0.9,139 blur = true,140}) => {141 const reduce = !!useReducedMotion();142 const ref = useRef<HTMLDivElement>(null);143 const inView = useInView(ref, { once: true, margin: "0px 0px -10% 0px" });144 const text = typeof number === "number" ? new Intl.NumberFormat(locale, formatOptions).format(number) : number;145 const chars = [...text];146
147 148 const prev = useRef<number | null>(null);149 const [tint, setTint] = useState<{ dir: "up" | "down"; token: number } | null>(null);150 useEffect(() => {151 if (typeof number !== "number") return;152 const before = prev.current;153 prev.current = number;154 if (!trend || before === null || before === number || !inView) return;155 setTint({ dir: number > before ? "up" : "down", token: Date.now() });156 const t = window.setTimeout(() => setTint(null), 900);157 return () => window.clearTimeout(t);158 }, [number, trend, inView]);159
160 161 162 let digitsSeen = 0;163 const columns = chars164 .map((ch, i) => ({ ch, place: chars.length - i }))165 .reverse()166 .map((c) => {167 const isDigit = c.ch >= "0" && c.ch <= "9";168 const order = isDigit ? digitsSeen++ : digitsSeen;169 return { ...c, isDigit, order };170 })171 .reverse();172
173 return (174 <div ref={ref} className={cn("flex flex-col items-start gap-1.5", className)}>175 <span className="sr-only" aria-live={live ? "polite" : undefined}>176 {prefix}177 {text}178 {suffix}179 </span>180 <div181 aria-hidden182 className={cn(183 "flex items-baseline font-(family-name:--font-display) leading-none font-medium tracking-[-0.03em] tabular-nums transition-colors duration-700",184 tint?.dir === "up" ? "text-emerald-500" : tint?.dir === "down" ? "text-red-500" : "text-(--color-ink)",185 digitClassName,186 )}187 style={{ fontSize: sizeMap[size] }}188 >189 {prefix ? <span className="mr-[0.04em] text-[0.6em] self-start mt-[0.12em] text-(--color-neutral)">{prefix}</span> : null}190 <AnimatePresence initial={false} mode="popLayout">191 {columns.map((c) => (192 <motion.span193 key={`${c.isDigit ? "d" : "s"}-${c.place}-${c.isDigit ? "" : c.ch}`}194 layout={reduce ? false : "position"}195 initial={reduce ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: "blur(4px)" }}196 animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}197 exit={reduce ? { opacity: 0 } : { opacity: 0, y: "-0.3em", filter: "blur(4px)" }}198 transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}199 className="inline-flex"200 >201 {c.isDigit ? (202 <Reel203 digit={Number(c.ch)}204 delay={reduce ? 0 : c.order * 0.06}205 duration={durationPerDigit}206 started={inView}207 reduce={reduce}208 blur={blur}209 />210 ) : (211 <span style={{ lineHeight: `${CELL}em` }}>{c.ch}</span>212 )}213 </motion.span>214 ))}215 </AnimatePresence>216 {suffix ? <span className="ml-[0.06em] text-[0.6em] text-(--color-neutral)">{suffix}</span> : null}217 </div>218 {label ? <div className="text-[13px] text-(--color-neutral)">{label}</div> : null}219 </div>220 );221};222
223224export const AnimatedDigit: React.FC<{225 target: number;226 size?: AnimatedSize;227 className?: string;228 blur?: boolean;229 durationPerDigit?: number;230}> = ({ target, size = "xl", className, blur, durationPerDigit }) => (231 <AnimatedNumber number={String(target)} size={size} className={className} blur={blur} durationPerDigit={durationPerDigit} />232);