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 { motion, useReducedMotion } from "motion/react";import React, { useEffect, useMemo, useState } from "react";
const sizeMap = {  sm: { fontSize: "1.5rem", height: "1.5rem", width: "1.75ch" },  md: { fontSize: "2rem", height: "2rem", width: "2ch" },  lg: { fontSize: "3rem", height: "3rem", width: "2.75ch" },  xl: { fontSize: "4rem", height: "4rem", width: "3.5ch" },  "2xl": { fontSize: "5rem", height: "5rem", width: "4.5ch" },} as const;
export type AnimatedSize = keyof typeof sizeMap;
interface AnimatedNumberProps {  number: number | string;  prefix?: string;  suffix?: string;  label?: string;  size?: AnimatedSize;  className?: string;  digitClassName?: string;  durationPerDigit?: number;  blur?: boolean;  direction?: "up" | "down";  easing?: [number, number, number, number];}
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({  number,  prefix = "",  suffix = "",  label,  size = "xl",  className,  digitClassName,  durationPerDigit = 0.28,  blur = false,  direction = "up",  easing = [0.23, 1, 0.32, 1],}) => {  const digits = useMemo(() => number.toString().split(""), [number]);  const sizeStyle = sizeMap[size];
  return (    <div className={cn("flex flex-col items-start gap-1", className)}>      <div className="flex items-end">        {prefix && <StaticChar char={prefix} sizeStyle={sizeStyle} />}
        {digits.map((char, idx) =>          /\d/.test(char) ? (            <AnimatedDigit              key={idx}              target={parseInt(char, 10)}              delay={idx * 40}              size={size}              durationPerDigit={durationPerDigit}              blur={blur}              direction={direction}              easing={easing}              className={digitClassName}            />          ) : (            <StaticChar key={idx} char={char} sizeStyle={sizeStyle} />          ),        )}
        {suffix && <StaticChar char={suffix} sizeStyle={sizeStyle} />}      </div>
      {label && (        <span className="text-sm tracking-wide text-(--color-ink-muted)">          {label}        </span>      )}    </div>  );};
const StaticChar: React.FC<{  char: string;  sizeStyle: { fontSize: string; height: string };}> = ({ char, sizeStyle }) => (  <span    className="font-light tabular-nums"    style={{      fontSize: sizeStyle.fontSize,      lineHeight: sizeStyle.height,    }}  >    {char}  </span>);
interface AnimatedDigitProps {  target: number;  delay?: number;  size?: AnimatedSize;  blur?: boolean;  durationPerDigit?: number;  direction?: "up" | "down";  easing?: [number, number, number, number];  className?: string;}
export const AnimatedDigit: React.FC<AnimatedDigitProps> = ({  target,  delay = 0,  size = "xl",  blur = false,  durationPerDigit = 0.28,  direction = "up",  easing = [0.23, 1, 0.32, 1],  className,}) => {  const reduce = useReducedMotion();  const { height, fontSize, width } = sizeMap[size];  const heightValue = parseFloat(height.replace("rem", ""));  const digits = useMemo(() => Array.from({ length: 10 }, (_, i) => i), []);  const [display, setDisplay] = useState(target);
  useEffect(() => {    if (reduce) setDisplay(target);  }, [target, reduce]);
  if (reduce) {    return (      <span        className={cn("inline-block text-center font-light tabular-nums", className)}        style={{ height, width, fontSize, lineHeight: height }}      >        {target}      </span>    );  }
  const startY =    direction === "up" ? "0rem" : `-${9 * heightValue}rem`;  const y =    direction === "up"      ? `-${target * heightValue}rem`      : `${target * heightValue}rem`;
  return (    <div      className={cn(        "inline-block overflow-hidden text-center tabular-nums",        blur && "bg-white/10 backdrop-blur-xs dark:bg-black/10",      )}      style={{ height, width, lineHeight: height }}    >      <motion.div        // Mount / remount (demo refresh) plays from the reel start;        // later target changes retarget interruptibly from the current y.        initial={{ y: startY }}        animate={{ y }}        transition={{          delay: delay / 1000,          duration: Math.min(durationPerDigit, 0.28),          ease: easing,        }}        onAnimationComplete={() => setDisplay(target)}      >        {digits.map((digit) => (          <div            key={digit}            className={cn("font-light select-none", className)}            style={{ height, fontSize }}            aria-hidden={digit !== display}          >            {digit}          </div>        ))}      </motion.div>    </div>  );};

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.