unlumen-ui
uianimate-digitssource: ok · hit
Animate Digits
Per-digit blur-slide animation — only the digits that change animate.
npx shadcn add "https://ui.unlumen.com/r/animate-digits.json"free
Visual style
glass-blur
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files1 · source cached 10/8/2026, 4:03:58 PM
registry/components/unlumen/animate-digits/index.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
interface AnimateDigitsProps {
value: string;
gap?: number;
className?: string;
digitClassName?: string;
enterStiffness?: number;
enterDamping?: number;
exitStiffness?: number;
exitDamping?: number;
direction?: "dynamic" | "up" | "down";
enterY?: number;
enterBlur?: number;
enterScale?: number;
animationDelay?: number;
}
interface ExitItem {
id: number;
char: string;
exitY: number;
}
let _id = 0;
function DigitCell({
char,
isDigit,
className,
enterStiffness = 170,
enterDamping = 10,
exitStiffness = 170,
exitDamping = 15,
direction = "dynamic",
enterY = 32,
enterBlur = 52,
enterScale = 0.7,
}: {
char: string;
isDigit: boolean;
className?: string;
enterStiffness?: number;
enterDamping?: number;
exitStiffness?: number;
exitDamping?: number;
direction?: "dynamic" | "up" | "down";
enterY?: number;
enterBlur?: number;
enterScale?: number;
}) {
const [exitQueue, setExitQueue] = useState<ExitItem[]>([]);
const prevCharRef = useRef(char);
const isFirstRender = useRef(true);
// Springs — jump() snaps the spring itself (position + velocity reset)
const springConfig = { stiffness: enterStiffness, damping: enterDamping };
const y = useSpring(0, springConfig);
const opacity = useSpring(1, springConfig);
const scale = useSpring(1, springConfig);
const blur = useSpring(0, springConfig);
const filter = useTransform(blur, (v) => `blur(${v}px)`);
useEffect(() => {
if (!isDigit) return;
const prev = prevCharRef.current;
prevCharRef.current = char;
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (char === prev || !/\d/.test(prev)) return;
const up =
direction === "dynamic"
? Number(char) > Number(prev)
: direction === "up";
const id = _id++;
setExitQueue((q) => {
const next = [...q, { id, char: prev, exitY: up ? -enterY : enterY }];
return next.length > 3 ? next.slice(-3) : next;
});
y.jump(up ? enterY : -enterY);
opacity.jump(0);
scale.jump(enterScale);
blur.jump(enterBlur);
y.set(0);
opacity.set(1);
scale.set(1);
blur.set(0);
}, [
char,
isDigit,
direction,
enterY,
enterBlur,
enterScale,
y,
opacity,
scale,
blur,
]);
if (!isDigit) {
return <span className={className}>{char}</span>;
}
return (
<div
className={cn(
"relative grid place-items-center [&>*]:col-start-1 [&>*]:row-start-1",
className,
)}
>
<AnimatePresence>
{exitQueue.map(({ id, char: exitChar, exitY }) => (
<motion.span
key={id}
aria-hidden
initial={{ opacity: 1, scale: 1, filter: "blur(0px)", y: 0 }}
animate={{ opacity: 0, scale: 0.7, filter: "blur(10px)", y: exitY }}
transition={{
type: "spring",
stiffness: exitStiffness,
damping: exitDamping,
}}
onAnimationComplete={() =>
setExitQueue((q) => q.filter((item) => item.id !== id))
}
>
{exitChar}
</motion.span>
))}
</AnimatePresence>
<motion.span style={{ opacity, scale, filter, y }}>{char}</motion.span>
</div>
);
}
function AnimateDigits({
value,
gap = 2,
className,
digitClassName,
enterStiffness,
enterDamping,
exitStiffness,
exitDamping,
direction,
enterY,
enterBlur,
enterScale,
animationDelay = 80,
}: AnimateDigitsProps) {
// displayedValue is what the cells actually render — advanced one step at a time
const [displayedValue, setDisplayedValue] = useState(value);
const pendingQueue = useRef<string[]>([]);
const isAnimating = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const displayedRef = useRef(value);
const processQueue = () => {
if (pendingQueue.current.length === 0) {
isAnimating.current = false;
return;
}
isAnimating.current = true;
const next = pendingQueue.current.shift()!;
displayedRef.current = next;
setDisplayedValue(next);
timerRef.current = setTimeout(processQueue, animationDelay);
};
useEffect(() => {
if (value === displayedRef.current) return;
pendingQueue.current.push(value);
if (!isAnimating.current) {
processQueue();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// When animationDelay changes while animating, the in-flight timer keeps the
// old delay for its last tick — acceptable; no need to restart the queue.
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const chars = displayedValue.split("");
return (
<div
className={cn("flex items-center tabular-nums", className)}
style={{ gap }}
>
{chars.map((char, i) => (
<DigitCell
key={i}
char={char}
isDigit={/\d/.test(char)}
className={digitClassName}
enterStiffness={enterStiffness}
enterDamping={enterDamping}
exitStiffness={exitStiffness}
exitDamping={exitDamping}
direction={direction}
enterY={enterY}
enterBlur={enterBlur}
enterScale={enterScale}
/>
))}
</div>
);
}
export { AnimateDigits, type AnimateDigitsProps };