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 { motion, useReducedMotion } from "motion/react";5import React, { useEffect, useMemo, useState } from "react";6 7const sizeMap = {8 sm: { fontSize: "1.5rem", height: "1.5rem", width: "1.75ch" },9 md: { fontSize: "2rem", height: "2rem", width: "2ch" },10 lg: { fontSize: "3rem", height: "3rem", width: "2.75ch" },11 xl: { fontSize: "4rem", height: "4rem", width: "3.5ch" },12 "2xl": { fontSize: "5rem", height: "5rem", width: "4.5ch" },13} as const;14 15export type AnimatedSize = keyof typeof sizeMap;16 17interface AnimatedNumberProps {18 number: number | string;19 prefix?: string;20 suffix?: string;21 label?: string;22 size?: AnimatedSize;23 className?: string;24 digitClassName?: string;25 durationPerDigit?: number;26 blur?: boolean;27 direction?: "up" | "down";28 easing?: [number, number, number, number];29}30 31export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({32 number,33 prefix = "",34 suffix = "",35 label,36 size = "xl",37 className,38 digitClassName,39 durationPerDigit = 0.28,40 blur = false,41 direction = "up",42 easing = [0.23, 1, 0.32, 1],43}) => {44 const digits = useMemo(() => number.toString().split(""), [number]);45 const sizeStyle = sizeMap[size];46 47 return (48 <div className={cn("flex flex-col items-start gap-1", className)}>49 <div className="flex items-end">50 {prefix && <StaticChar char={prefix} sizeStyle={sizeStyle} />}51 52 {digits.map((char, idx) =>53 /\d/.test(char) ? (54 <AnimatedDigit55 key={idx}56 target={parseInt(char, 10)}57 delay={idx * 40}58 size={size}59 durationPerDigit={durationPerDigit}60 blur={blur}61 direction={direction}62 easing={easing}63 className={digitClassName}64 />65 ) : (66 <StaticChar key={idx} char={char} sizeStyle={sizeStyle} />67 ),68 )}69 70 {suffix && <StaticChar char={suffix} sizeStyle={sizeStyle} />}71 </div>72 73 {label && (74 <span className="text-sm tracking-wide text-(--color-ink-muted)">75 {label}76 </span>77 )}78 </div>79 );80};81 82const StaticChar: React.FC<{83 char: string;84 sizeStyle: { fontSize: string; height: string };85}> = ({ char, sizeStyle }) => (86 <span87 className="font-light tabular-nums"88 style={{89 fontSize: sizeStyle.fontSize,90 lineHeight: sizeStyle.height,91 }}92 >93 {char}94 </span>95);96 97interface AnimatedDigitProps {98 target: number;99 delay?: number;100 size?: AnimatedSize;101 blur?: boolean;102 durationPerDigit?: number;103 direction?: "up" | "down";104 easing?: [number, number, number, number];105 className?: string;106}107 108export const AnimatedDigit: React.FC<AnimatedDigitProps> = ({109 target,110 delay = 0,111 size = "xl",112 blur = false,113 durationPerDigit = 0.28,114 direction = "up",115 easing = [0.23, 1, 0.32, 1],116 className,117}) => {118 const reduce = useReducedMotion();119 const { height, fontSize, width } = sizeMap[size];120 const heightValue = parseFloat(height.replace("rem", ""));121 const digits = useMemo(() => Array.from({ length: 10 }, (_, i) => i), []);122 const [display, setDisplay] = useState(target);123 124 useEffect(() => {125 if (reduce) setDisplay(target);126 }, [target, reduce]);127 128 if (reduce) {129 return (130 <span131 className={cn("inline-block text-center font-light tabular-nums", className)}132 style={{ height, width, fontSize, lineHeight: height }}133 >134 {target}135 </span>136 );137 }138 139 const startY =140 direction === "up" ? "0rem" : `-${9 * heightValue}rem`;141 const y =142 direction === "up"143 ? `-${target * heightValue}rem`144 : `${target * heightValue}rem`;145 146 return (147 <div148 className={cn(149 "inline-block overflow-hidden text-center tabular-nums",150 blur && "bg-white/10 backdrop-blur-xs dark:bg-black/10",151 )}152 style={{ height, width, lineHeight: height }}153 >154 <motion.div155 // Mount / remount (demo refresh) plays from the reel start;156 // later target changes retarget interruptibly from the current y.157 initial={{ y: startY }}158 animate={{ y }}159 transition={{160 delay: delay / 1000,161 duration: Math.min(durationPerDigit, 0.28),162 ease: easing,163 }}164 onAnimationComplete={() => setDisplay(target)}165 >166 {digits.map((digit) => (167 <div168 key={digit}169 className={cn("font-light select-none", className)}170 style={{ height, fontSize }}171 aria-hidden={digit !== display}172 >173 {digit}174 </div>175 ))}176 </motion.div>177 </div>178 );179};
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.