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 React, { useEffect, useRef, useState } from "react";import {  AnimatePresence,  animate,  motion,  useInView,  useMotionValue,  useReducedMotion,  useTransform,  useVelocity,} from "motion/react";import { cn } from "@/lib/utils";
const sizeMap = {  sm: "1.5rem",  md: "2rem",  lg: "3rem",  xl: "4rem",  "2xl": "5rem",} as const;
export type AnimatedSize = keyof typeof sizeMap;
export type AnimatedNumberProps = {  /** A number is formatted with `locale` / `formatOptions`; a string is shown as-is. */  number: number | string;  prefix?: string;  suffix?: string;  label?: string;  size?: AnimatedSize;  locale?: string;  formatOptions?: Intl.NumberFormatOptions;  /** Tint the digits green or red for a moment when a numeric value rises or falls. */  trend?: boolean;  /** Announce changes to screen readers. */  live?: boolean;  className?: string;  digitClassName?: string;  /** Seconds each reel takes to settle. */  durationPerDigit?: number;  /** Blur the reels while they move. */  blur?: boolean;  /** @deprecated Reels take the shortest way round. */  direction?: "up" | "down";  /** @deprecated Reels use a spring. */  easing?: [number, number, number, number];};
const CELL = 1.1; // em, per digit row
function Reel({  digit,  delay,  duration,  started,  reduce,  blur,}: {  digit: number;  delay: number;  duration: number;  started: boolean;  reduce: boolean;  blur: boolean;}) {  // `pos` is unbounded so a 9 → 0 change rolls forward one step instead of  // spinning back through every digit.  const pos = useMotionValue(0);  const y = useTransform(pos, (p) => `${-(((p % 10) + 10) % 10) * CELL}em`);  const velocity = useVelocity(pos);  const filter = useTransform(velocity, (v) =>    blur && !reduce ? `blur(${Math.min(Math.abs(v) * 0.09, 2.4).toFixed(2)}px)` : "none",  );  const first = useRef(true);
  useEffect(() => {    if (!started) return;    const current = pos.get();    const from = ((current % 10) + 10) % 10;    let delta = digit - from;    // On the first roll, spin up through a full turn for a little drama.    if (first.current && !reduce) delta += 10;    else if (delta > 5) delta -= 10;    else if (delta < -5) delta += 10;    first.current = false;    if (reduce) {      pos.jump(current + delta);      return;    }    const controls = animate(pos, current + delta, {      type: "spring",      bounce: 0.15,      duration,      delay,    });    return () => controls.stop();  }, [digit, started, reduce, delay, duration, pos]);
  // The strip ends with a second 0 so a roll past 9 lands seamlessly.  return (    <span      className="relative inline-block overflow-hidden"      style={{        height: `${CELL}em`,        maskImage: "linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent)",        WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent)",      }}    >      {/* Invisible sizer keeps the column one digit wide. */}      <span className="invisible block" style={{ lineHeight: `${CELL}em` }}>        0      </span>      <motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" style={{ y, filter }}>        {[...Array(11)].map((_, i) => (          <span key={i} className="block" style={{ height: `${CELL}em`, lineHeight: `${CELL}em` }}>            {i % 10}          </span>        ))}      </motion.span>    </span>  );}
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({  number,  prefix = "",  suffix = "",  label,  size = "xl",  locale,  formatOptions,  trend = true,  live = false,  className,  digitClassName,  durationPerDigit = 0.9,  blur = true,}) => {  const reduce = !!useReducedMotion();  const ref = useRef<HTMLDivElement>(null);  const inView = useInView(ref, { once: true, margin: "0px 0px -10% 0px" });  const text = typeof number === "number" ? new Intl.NumberFormat(locale, formatOptions).format(number) : number;  const chars = [...text];
  // Brief colour hint when a numeric value moves.  const prev = useRef<number | null>(null);  const [tint, setTint] = useState<{ dir: "up" | "down"; token: number } | null>(null);  useEffect(() => {    if (typeof number !== "number") return;    const before = prev.current;    prev.current = number;    if (!trend || before === null || before === number || !inView) return;    setTint({ dir: number > before ? "up" : "down", token: Date.now() });    const t = window.setTimeout(() => setTint(null), 900);    return () => window.clearTimeout(t);  }, [number, trend, inView]);
  // Key columns by place value from the right, so 999 → 1,000 adds a column  // on the left instead of re-rolling every digit.  let digitsSeen = 0;  const columns = chars    .map((ch, i) => ({ ch, place: chars.length - i }))    .reverse()    .map((c) => {      const isDigit = c.ch >= "0" && c.ch <= "9";      const order = isDigit ? digitsSeen++ : digitsSeen;      return { ...c, isDigit, order };    })    .reverse();
  return (    <div ref={ref} className={cn("flex flex-col items-start gap-1.5", className)}>      <span className="sr-only" aria-live={live ? "polite" : undefined}>        {prefix}        {text}        {suffix}      </span>      <div        aria-hidden        className={cn(          "flex items-baseline font-(family-name:--font-display) leading-none font-medium tracking-[-0.03em] tabular-nums transition-colors duration-700",          tint?.dir === "up" ? "text-emerald-500" : tint?.dir === "down" ? "text-red-500" : "text-(--color-ink)",          digitClassName,        )}        style={{ fontSize: sizeMap[size] }}      >        {prefix ? <span className="mr-[0.04em] text-[0.6em] self-start mt-[0.12em] text-(--color-neutral)">{prefix}</span> : null}        <AnimatePresence initial={false} mode="popLayout">          {columns.map((c) => (            <motion.span              key={`${c.isDigit ? "d" : "s"}-${c.place}-${c.isDigit ? "" : c.ch}`}              layout={reduce ? false : "position"}              initial={reduce ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: "blur(4px)" }}              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}              exit={reduce ? { opacity: 0 } : { opacity: 0, y: "-0.3em", filter: "blur(4px)" }}              transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}              className="inline-flex"            >              {c.isDigit ? (                <Reel                  digit={Number(c.ch)}                  delay={reduce ? 0 : c.order * 0.06}                  duration={durationPerDigit}                  started={inView}                  reduce={reduce}                  blur={blur}                />              ) : (                <span style={{ lineHeight: `${CELL}em` }}>{c.ch}</span>              )}            </motion.span>          ))}        </AnimatePresence>        {suffix ? <span className="ml-[0.06em] text-[0.6em] text-(--color-neutral)">{suffix}</span> : null}      </div>      {label ? <div className="text-[13px] text-(--color-neutral)">{label}</div> : null}    </div>  );};
/** Single-digit reel, kept for API compatibility. */export const AnimatedDigit: React.FC<{  target: number;  size?: AnimatedSize;  className?: string;  blur?: boolean;  durationPerDigit?: number;}> = ({ target, size = "xl", className, blur, durationPerDigit }) => (  <AnimatedNumber number={String(target)} size={size} className={className} blur={blur} durationPerDigit={durationPerDigit} />);

Update the import paths to match your project setup.

Basic Usage

tsx
import { AnimatedNumber } from "@/components/ui/animated-number";
<AnimatedNumber  number={revenue}  formatOptions={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}  label="Revenue this month"  size="xl"  live/>
<AnimatedNumber number={99.98} suffix="%" label="Uptime" size="md" trend={false} />

Props

PropTypeRequiredDefaultDescription
numbernumber | stringYes—A number is formatted with locale and formatOptions. A string such as "1,234" is shown as-is; only its digits roll.
locale / formatOptionsstring / Intl.NumberFormatOptionsNo—Formatting for numeric values, e.g. currency, compact notation or fixed decimals.
prefix / suffixstringNo—Text before or after the digits, set smaller and muted.
labelstringNo—Caption under the number.
size"sm" | "md" | "lg" | "xl" | "2xl"No"xl"Font size, from 1.5rem to 5rem.
trendbooleanNotrueTint the digits green or red for a moment when a numeric value rises or falls.
blurbooleanNotrueBlur the reels in proportion to their speed.
durationPerDigitnumberNo0.9Seconds each reel takes to settle.
livebooleanNofalseAnnounce value changes to screen readers. Leave off for numbers that tick often.
className / digitClassNamestringNo—Classes for the wrapper and the digit row.

Best Practices

  1. Pass a number and let formatOptions handle separators and currency, so place values stay aligned as it changes.
  2. Reels start when the number scrolls into view, so put it where people will see the roll.
  3. Screen readers get the full formatted value as text; the reels themselves are hidden from them.
  4. Reduced motion skips the spin and shows the new digits immediately.