Overview
Count-up style numbers with optional prefix, label, blur, and per-digit timing.
Installation
Use the following command to install the component:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/animated-number.jsonInstall the following dependencies:
$ pnpm add motionAdd util file
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy and paste the following code into your project.
tsx
1"use client";2 3import { cn } from "@/lib/utils";4import React, {5 useEffect,6 useRef,7 type CSSProperties,8} from "react";9 10const sizeCellMap = {11 sm: "1.5rem",12 md: "2rem",13 lg: "3rem",14 xl: "4rem",15 "2xl": "5rem",16} as const;17 18export type AnimatedSize = keyof typeof sizeCellMap;19 20interface AnimatedNumberProps {21 number: number | string;22 prefix?: string;23 suffix?: string;24 label?: string;25 size?: AnimatedSize;26 className?: string;27 /** Applied to the reel root (kept for API compat). */28 digitClassName?: string;29 /** Unused — timing comes from `--reel-dur` / `--reel-stagger`. */30 durationPerDigit?: number;31 /** Unused — spin blur comes from `--reel-spin-blur`. */32 blur?: boolean;33 /** Unused — reels always spin upward. */34 direction?: "up" | "down";35 /** Unused — easing comes from `--reel-ease`. */36 easing?: [number, number, number, number];37}38 39const BLUR_MAX = 10;40const SPINS = 3;41 42function readMs(name: string, fallback: number): number {43 const raw = getComputedStyle(document.documentElement)44 .getPropertyValue(name)45 .trim();46 if (!raw) return fallback;47 if (raw.endsWith("ms")) return parseFloat(raw);48 if (raw.endsWith("s")) return parseFloat(raw) * 1000;49 const n = parseFloat(raw);50 return Number.isFinite(n) ? n : fallback;51}52 53function readNum(name: string, fallback: number): number {54 const v = parseFloat(55 getComputedStyle(document.documentElement).getPropertyValue(name),56 );57 return Number.isFinite(v) ? v : fallback;58}59 60function ensureReelBlurDefs() {61 if (typeof document === "undefined") return;62 if (document.getElementById("t-reel-blur-defs")) return;63 const svgNS = "http://www.w3.org/2000/svg";64 const svg = document.createElementNS(svgNS, "svg");65 svg.setAttribute("id", "t-reel-blur-defs");66 svg.setAttribute("aria-hidden", "true");67 Object.assign(svg.style, {68 position: "absolute",69 width: "0",70 height: "0",71 overflow: "hidden",72 });73 const defs = document.createElementNS(svgNS, "defs");74 for (let i = 0; i < BLUR_MAX; i++) {75 const filter = document.createElementNS(svgNS, "filter");76 filter.setAttribute("id", `t-reel-blur-${i}`);77 filter.setAttribute("x", "-20%");78 filter.setAttribute("y", "-150%");79 filter.setAttribute("width", "140%");80 filter.setAttribute("height", "400%");81 const fe = document.createElementNS(svgNS, "feGaussianBlur");82 fe.setAttribute("id", `t-reel-blur-fe-${i}`);83 fe.setAttribute("stdDeviation", "0 0");84 filter.appendChild(fe);85 defs.appendChild(filter);86 }87 svg.appendChild(defs);88 document.body.appendChild(svg);89}90 91function setBlur(blurIndex: number, y: number | string) {92 const fe = document.getElementById(`t-reel-blur-fe-${blurIndex}`);93 if (fe) fe.setAttribute("stdDeviation", `0 ${y}`);94}95 96type ReelCol = {97 strip: HTMLElement;98 digit: number;99 blurIndex: number;100};101 102function buildReels(el: HTMLElement, str: string, spins: number): ReelCol[] {103 el.replaceChildren();104 const cols: ReelCol[] = [];105 const total = (spins + 1) * 10;106 for (let c = 0; c < str.length; c++) {107 const ch = str[c];108 if (ch >= "0" && ch <= "9") {109 const col = document.createElement("span");110 col.className = "t-reel-col";111 const strip = document.createElement("span");112 strip.className = "t-reel-strip";113 for (let k = 0; k < total; k++) {114 const cell = document.createElement("span");115 cell.className = "t-reel-digit";116 cell.textContent = String(k % 10);117 strip.appendChild(cell);118 }119 col.appendChild(strip);120 el.appendChild(col);121 const blurIndex = cols.length % BLUR_MAX;122 strip.style.filter = `url(#t-reel-blur-${blurIndex})`;123 cols.push({ strip, digit: +ch, blurIndex });124 } else {125 const sep = document.createElement("span");126 sep.className = "t-reel-digit";127 sep.style.height = "var(--reel-cell)";128 sep.style.display = "flex";129 sep.style.alignItems = "center";130 sep.textContent = ch;131 el.appendChild(sep);132 }133 }134 return cols;135}136 137function cellPx(reel: HTMLElement): number {138 const digit = reel.querySelector<HTMLElement>(".t-reel-digit");139 const measured = digit?.getBoundingClientRect().height ?? 0;140 if (measured > 0) return measured;141 return readNum("--reel-cell", 30);142}143 144function spinReel(el: HTMLElement, value: string | number, reduce: boolean) {145 ensureReelBlurDefs();146 const str = String(value);147 const spins = reduce ? 0 : SPINS;148 const blurAmt = readNum("--reel-spin-blur", 3);149 const dur = readMs("--reel-dur", 1400);150 const stagger = readMs("--reel-stagger", 90);151 const ease =152 getComputedStyle(document.documentElement)153 .getPropertyValue("--reel-ease")154 .trim() || "cubic-bezier(0.16, 1, 0.3, 1)";155 156 el.setAttribute("aria-label", str);157 const cols = buildReels(el, str, Math.max(spins, 1));158 const cell = cellPx(el);159 160 cols.forEach((c) => {161 c.strip.style.transition = "none";162 c.strip.style.transform = "translateY(0px)";163 setBlur(c.blurIndex, reduce ? 0 : blurAmt);164 });165 void el.offsetWidth;166 167 cols.forEach((c, i) => {168 const delay = reduce ? 0 : i * stagger;169 const land = (spins * 10 + c.digit) * cell;170 if (reduce) {171 c.strip.style.transition = "none";172 c.strip.style.transform = `translateY(${-land}px)`;173 setBlur(c.blurIndex, 0);174 return;175 }176 c.strip.style.transition = `transform ${dur}ms ${ease} ${delay}ms`;177 c.strip.style.transform = `translateY(${-land}px)`;178 });179 180 const prev = (el as HTMLElement & { _reelRaf?: number })._reelRaf;181 if (prev) cancelAnimationFrame(prev);182 if (reduce) return;183 184 const t0 = performance.now();185 const maxEnd = (cols.length - 1) * stagger + dur;186 const tick = (now: number) => {187 const t = now - t0;188 cols.forEach((c, i) => {189 const local = Math.min(1, Math.max(0, (t - i * stagger) / dur));190 setBlur(c.blurIndex, (blurAmt * (1 - local)).toFixed(2));191 });192 if (t < maxEnd + 40) {193 (el as HTMLElement & { _reelRaf?: number })._reelRaf =194 requestAnimationFrame(tick);195 } else {196 cols.forEach((c) => setBlur(c.blurIndex, 0));197 (el as HTMLElement & { _reelRaf?: number })._reelRaf = undefined;198 }199 };200 (el as HTMLElement & { _reelRaf?: number })._reelRaf =201 requestAnimationFrame(tick);202}203 204function usePrefersReducedMotion() {205 const ref = useRef(false);206 useEffect(() => {207 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");208 ref.current = mq.matches;209 const sync = () => {210 ref.current = mq.matches;211 };212 mq.addEventListener("change", sync);213 return () => mq.removeEventListener("change", sync);214 }, []);215 return ref;216}217 218export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({219 number,220 prefix = "",221 suffix = "",222 label,223 size = "xl",224 className,225 digitClassName,226}) => {227 const reelRef = useRef<HTMLDivElement>(null);228 const reduceRef = usePrefersReducedMotion();229 const cell = sizeCellMap[size];230 231 useEffect(() => {232 const el = reelRef.current;233 if (!el) return;234 spinReel(el, number, reduceRef.current);235 return () => {236 const raf = (el as HTMLElement & { _reelRaf?: number })._reelRaf;237 if (raf) cancelAnimationFrame(raf);238 };239 }, [number, size, reduceRef]);240 241 return (242 <div243 className={cn("flex flex-col items-start gap-1", className)}244 style={245 {246 "--reel-cell": cell,247 fontSize: cell,248 lineHeight: cell,249 } as CSSProperties250 }251 >252 <div className="flex items-end font-light tabular-nums">253 {prefix ? (254 <span className="t-reel-digit" aria-hidden>255 {prefix}256 </span>257 ) : null}258 259 <div260 ref={reelRef}261 className={cn("t-reel", digitClassName)}262 aria-live="polite"263 />264 265 {suffix ? (266 <span className="t-reel-digit" aria-hidden>267 {suffix}268 </span>269 ) : null}270 </div>271 272 {label ? (273 <span className="text-sm tracking-wide text-(--color-ink-muted)">274 {label}275 </span>276 ) : null}277 </div>278 );279};280 281/** Single-digit reel — kept for API compat with the prior export. */282export const AnimatedDigit: React.FC<{283 target: number;284 size?: AnimatedSize;285 className?: string;286 delay?: number;287 blur?: boolean;288 durationPerDigit?: number;289 direction?: "up" | "down";290 easing?: [number, number, number, number];291}> = ({ target, size = "xl", className }) => (292 <AnimatedNumber number={target} size={size} className={className} />293);
Update the import paths to match your project setup.
Basic Usage
tsx
1<AnimatedNumber number={1234567890} />Advanced Usage
tsx
1<AnimatedNumber number={1234567890} />Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| number | number | No | — | The number to animate |
Best Practices
- Keep
durationPerDigit≤0.28; digits retarget interruptibly from the live value. - Under reduced motion, snap to the target digit with no reel.
- Always use
tabular-numsso digit width stays stable. - Prefer short labels under the figure — avoid competing motion nearby.