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.json

Install the following dependencies:

$ pnpm add motion

Add util file

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy and paste the following code into your project.

components/ui/animated-number.tsx
tsx
"use client";
import { cn } from "@/lib/utils";import React, {  useEffect,  useRef,  type CSSProperties,} from "react";
const sizeCellMap = {  sm: "1.5rem",  md: "2rem",  lg: "3rem",  xl: "4rem",  "2xl": "5rem",} as const;
export type AnimatedSize = keyof typeof sizeCellMap;
interface AnimatedNumberProps {  number: number | string;  prefix?: string;  suffix?: string;  label?: string;  size?: AnimatedSize;  className?: string;  /** Applied to the reel root (kept for API compat). */  digitClassName?: string;  /** Unused — timing comes from `--reel-dur` / `--reel-stagger`. */  durationPerDigit?: number;  /** Unused — spin blur comes from `--reel-spin-blur`. */  blur?: boolean;  /** Unused — reels always spin upward. */  direction?: "up" | "down";  /** Unused — easing comes from `--reel-ease`. */  easing?: [number, number, number, number];}
const BLUR_MAX = 10;const SPINS = 3;
function readMs(name: string, fallback: number): number {  const raw = getComputedStyle(document.documentElement)    .getPropertyValue(name)    .trim();  if (!raw) return fallback;  if (raw.endsWith("ms")) return parseFloat(raw);  if (raw.endsWith("s")) return parseFloat(raw) * 1000;  const n = parseFloat(raw);  return Number.isFinite(n) ? n : fallback;}
function readNum(name: string, fallback: number): number {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue(name),  );  return Number.isFinite(v) ? v : fallback;}
function ensureReelBlurDefs() {  if (typeof document === "undefined") return;  if (document.getElementById("t-reel-blur-defs")) return;  const svgNS = "http://www.w3.org/2000/svg";  const svg = document.createElementNS(svgNS, "svg");  svg.setAttribute("id", "t-reel-blur-defs");  svg.setAttribute("aria-hidden", "true");  Object.assign(svg.style, {    position: "absolute",    width: "0",    height: "0",    overflow: "hidden",  });  const defs = document.createElementNS(svgNS, "defs");  for (let i = 0; i < BLUR_MAX; i++) {    const filter = document.createElementNS(svgNS, "filter");    filter.setAttribute("id", `t-reel-blur-${i}`);    filter.setAttribute("x", "-20%");    filter.setAttribute("y", "-150%");    filter.setAttribute("width", "140%");    filter.setAttribute("height", "400%");    const fe = document.createElementNS(svgNS, "feGaussianBlur");    fe.setAttribute("id", `t-reel-blur-fe-${i}`);    fe.setAttribute("stdDeviation", "0 0");    filter.appendChild(fe);    defs.appendChild(filter);  }  svg.appendChild(defs);  document.body.appendChild(svg);}
function setBlur(blurIndex: number, y: number | string) {  const fe = document.getElementById(`t-reel-blur-fe-${blurIndex}`);  if (fe) fe.setAttribute("stdDeviation", `0 ${y}`);}
type ReelCol = {  strip: HTMLElement;  digit: number;  blurIndex: number;};
function buildReels(el: HTMLElement, str: string, spins: number): ReelCol[] {  el.replaceChildren();  const cols: ReelCol[] = [];  const total = (spins + 1) * 10;  for (let c = 0; c < str.length; c++) {    const ch = str[c];    if (ch >= "0" && ch <= "9") {      const col = document.createElement("span");      col.className = "t-reel-col";      const strip = document.createElement("span");      strip.className = "t-reel-strip";      for (let k = 0; k < total; k++) {        const cell = document.createElement("span");        cell.className = "t-reel-digit";        cell.textContent = String(k % 10);        strip.appendChild(cell);      }      col.appendChild(strip);      el.appendChild(col);      const blurIndex = cols.length % BLUR_MAX;      strip.style.filter = `url(#t-reel-blur-${blurIndex})`;      cols.push({ strip, digit: +ch, blurIndex });    } else {      const sep = document.createElement("span");      sep.className = "t-reel-digit";      sep.style.height = "var(--reel-cell)";      sep.style.display = "flex";      sep.style.alignItems = "center";      sep.textContent = ch;      el.appendChild(sep);    }  }  return cols;}
function cellPx(reel: HTMLElement): number {  const digit = reel.querySelector<HTMLElement>(".t-reel-digit");  const measured = digit?.getBoundingClientRect().height ?? 0;  if (measured > 0) return measured;  return readNum("--reel-cell", 30);}
function spinReel(el: HTMLElement, value: string | number, reduce: boolean) {  ensureReelBlurDefs();  const str = String(value);  const spins = reduce ? 0 : SPINS;  const blurAmt = readNum("--reel-spin-blur", 3);  const dur = readMs("--reel-dur", 1400);  const stagger = readMs("--reel-stagger", 90);  const ease =    getComputedStyle(document.documentElement)      .getPropertyValue("--reel-ease")      .trim() || "cubic-bezier(0.16, 1, 0.3, 1)";
  el.setAttribute("aria-label", str);  const cols = buildReels(el, str, Math.max(spins, 1));  const cell = cellPx(el);
  cols.forEach((c) => {    c.strip.style.transition = "none";    c.strip.style.transform = "translateY(0px)";    setBlur(c.blurIndex, reduce ? 0 : blurAmt);  });  void el.offsetWidth;
  cols.forEach((c, i) => {    const delay = reduce ? 0 : i * stagger;    const land = (spins * 10 + c.digit) * cell;    if (reduce) {      c.strip.style.transition = "none";      c.strip.style.transform = `translateY(${-land}px)`;      setBlur(c.blurIndex, 0);      return;    }    c.strip.style.transition = `transform ${dur}ms ${ease} ${delay}ms`;    c.strip.style.transform = `translateY(${-land}px)`;  });
  const prev = (el as HTMLElement & { _reelRaf?: number })._reelRaf;  if (prev) cancelAnimationFrame(prev);  if (reduce) return;
  const t0 = performance.now();  const maxEnd = (cols.length - 1) * stagger + dur;  const tick = (now: number) => {    const t = now - t0;    cols.forEach((c, i) => {      const local = Math.min(1, Math.max(0, (t - i * stagger) / dur));      setBlur(c.blurIndex, (blurAmt * (1 - local)).toFixed(2));    });    if (t < maxEnd + 40) {      (el as HTMLElement & { _reelRaf?: number })._reelRaf =        requestAnimationFrame(tick);    } else {      cols.forEach((c) => setBlur(c.blurIndex, 0));      (el as HTMLElement & { _reelRaf?: number })._reelRaf = undefined;    }  };  (el as HTMLElement & { _reelRaf?: number })._reelRaf =    requestAnimationFrame(tick);}
function usePrefersReducedMotion() {  const ref = useRef(false);  useEffect(() => {    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");    ref.current = mq.matches;    const sync = () => {      ref.current = mq.matches;    };    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return ref;}
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({  number,  prefix = "",  suffix = "",  label,  size = "xl",  className,  digitClassName,}) => {  const reelRef = useRef<HTMLDivElement>(null);  const reduceRef = usePrefersReducedMotion();  const cell = sizeCellMap[size];
  useEffect(() => {    const el = reelRef.current;    if (!el) return;    spinReel(el, number, reduceRef.current);    return () => {      const raf = (el as HTMLElement & { _reelRaf?: number })._reelRaf;      if (raf) cancelAnimationFrame(raf);    };  }, [number, size, reduceRef]);
  return (    <div      className={cn("flex flex-col items-start gap-1", className)}      style={        {          "--reel-cell": cell,          fontSize: cell,          lineHeight: cell,        } as CSSProperties      }    >      <div className="flex items-end font-light tabular-nums">        {prefix ? (          <span className="t-reel-digit" aria-hidden>            {prefix}          </span>        ) : null}
        <div          ref={reelRef}          className={cn("t-reel", digitClassName)}          aria-live="polite"        />
        {suffix ? (          <span className="t-reel-digit" aria-hidden>            {suffix}          </span>        ) : null}      </div>
      {label ? (        <span className="text-sm tracking-wide text-(--color-ink-muted)">          {label}        </span>      ) : null}    </div>  );};
/** Single-digit reel — kept for API compat with the prior export. */export const AnimatedDigit: React.FC<{  target: number;  size?: AnimatedSize;  className?: string;  delay?: number;  blur?: boolean;  durationPerDigit?: number;  direction?: "up" | "down";  easing?: [number, number, number, number];}> = ({ target, size = "xl", className }) => (  <AnimatedNumber number={target} size={size} className={className} />);

Update the import paths to match your project setup.

Basic Usage

tsx
<AnimatedNumber number={1234567890} />

Advanced Usage

tsx
<AnimatedNumber number={1234567890} />

Props

PropTypeRequiredDefaultDescription
numbernumberNoThe number to animate

Best Practices

  1. Keep durationPerDigit0.28; digits retarget interruptibly from the live value.
  2. Under reduced motion, snap to the target digit with no reel.
  3. Always use tabular-nums so digit width stays stable.
  4. Prefer short labels under the figure — avoid competing motion nearby.