An animated number counter that springs to its target value when it enters the viewport, with support for decimals, separators, and direction.
Made by Léousers
uptime %
issues
"use client";
import {
CountUp,
type DigitEffect,
} from "@/components/unlumen-ui/count-up";
interface CountUpDemoProps {
duration: number;
separator: string;
digitEffect: DigitEffect;
}
export default function CountUpDemo({
duration = 2,
separator = ",",
digitEffect = "none",
}: CountUpDemoProps) {
return (
<div className="flex flex-wrap gap-12 items-end justify-center p-8">
<div className="flex flex-col items-center gap-1">
<CountUp
to={1000000}
duration={duration}
separator={separator}
digitEffect={digitEffect}
className="text-5xl font-bold tabular-nums tracking-tight"
/>
<span className="text-xs text-muted-foreground">users</span>
</div>
<div className="flex flex-col items-center gap-1">
<CountUp
to={99.9}
duration={duration}
digitEffect={digitEffect}
className="text-5xl font-bold tabular-nums tracking-tight"
/>
<span className="text-xs text-muted-foreground">uptime %</span>
</div>
<div className="flex flex-col items-center gap-1">
<CountUp
to={0}
from={5}
direction="down"
duration={duration}
separator={separator}
digitEffect={digitEffect}
className="text-5xl font-bold tabular-nums tracking-tight"
/>
<span className="text-xs text-muted-foreground">issues</span>
</div>
</div>
);
}Installation
Install the following dependencies:
Copy and paste the following code into your project:
"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useMotionValue,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import useMeasure from "react-use-measure";
import { cn } from "@/lib/utils";
type DigitEffect = "none" | "fade" | "blur" | "slide";
interface CountUpProps {
to: number;
from?: number;
direction?: "up" | "down";
delay?: number;
duration?: number;
digitEffect?: DigitEffect;
className?: string;
startWhen?: boolean;
separator?: string;
onStart?: () => void;
onEnd?: () => void;
}
type OdometerDigitProps = {
springValue: MotionValue<number>;
/** place value: 1, 10, 100, 1000 … */
place: number;
};
function OdometerDigit({ springValue, place }: OdometerDigitProps) {
const [ref, { height }] = useMeasure();
const y = useTransform(springValue, (v) => {
if (!height) return 0;
const digit = (Math.abs(v) / place) % 10;
return -digit * height;
});
return (
<span
style={{
position: "relative",
display: "inline-block",
width: "1ch",
overflowY: "clip",
overflowX: "visible",
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
}}
>
<span ref={ref} style={{ visibility: "hidden", display: "block" }}>
0
</span>
<motion.span
style={{
y,
position: "absolute",
top: 0,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
}}
>
{/* 11 digits (0–9 + repeated 0) so the 9→0 wrap is seamless */}
{Array.from({ length: 11 }, (_, i) => (
<span
key={i}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: height || "1em",
}}
>
{i % 10}
</span>
))}
</motion.span>
</span>
);
}
type CharSlotProps = {
char: string;
charKey: string;
effect: Exclude<DigitEffect, "none" | "slide">;
countingUp: boolean;
};
const CHAR_VARIANTS = {
fade: {
initial: { opacity: 0, scale: 0.7 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.7 },
transition: { duration: 0.14, ease: "easeOut" },
overflow: "hidden" as const,
},
blur: {
initial: (up: boolean) => ({
opacity: 0,
filter: "blur(8px)",
y: up ? -8 : 8,
}),
animate: { opacity: 1, filter: "blur(0px)", y: 0 },
exit: (up: boolean) => ({
opacity: 0,
filter: "blur(8px)",
y: up ? 8 : -8,
}),
transition: { duration: 0.18, ease: "easeOut" },
overflow: "visible" as const,
},
};
function CharSlot({ char, charKey, effect, countingUp }: CharSlotProps) {
const isDigit = /\d/.test(char);
if (!isDigit) {
return <span style={{ display: "inline-block" }}>{char}</span>;
}
const v = CHAR_VARIANTS[effect];
const initial =
typeof v.initial === "function" ? v.initial(countingUp) : v.initial;
const exit =
"exit" in v && typeof v.exit === "function"
? (v.exit as (up: boolean) => object)(countingUp)
: (v.exit as object);
return (
<span
style={{
position: "relative",
display: "inline-block",
overflow: v.overflow,
}}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={charKey}
initial={initial}
animate={v.animate}
transition={v.transition as object}
style={{ display: "inline-block" }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
}
function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2,
digitEffect = "none",
className,
startWhen = true,
separator = "",
onStart,
onEnd,
}: CountUpProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? to : from);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, { damping, stiffness });
const isInView = useInView(ref, { once: true, margin: "0px" });
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const parts = str.split(".");
const decimals = parts[1];
if (decimals && parseInt(decimals) !== 0) return decimals.length;
}
return 0;
};
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
const formatValue = React.useCallback(
(latest: number) => {
const hasDecimals = maxDecimals > 0;
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
};
const formatted = Intl.NumberFormat("en-US", options).format(latest);
return separator ? formatted.replace(/,/g, separator) : formatted;
},
[maxDecimals, separator],
);
const initialStr = formatValue(direction === "down" ? to : from);
const [chars, setChars] = React.useState<string[]>(initialStr.split(""));
React.useEffect(() => {
const initial = formatValue(direction === "down" ? to : from);
if (digitEffect === "none") {
if (ref.current) ref.current.textContent = initial;
} else if (digitEffect !== "slide") {
setChars(initial.split(""));
}
}, [from, to, direction, formatValue, digitEffect]);
React.useEffect(() => {
if (isInView && startWhen) {
onStart?.();
const t1 = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const t2 = setTimeout(() => onEnd?.(), delay * 1000 + duration * 1000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}
}, [
isInView,
startWhen,
motionValue,
direction,
from,
to,
delay,
onStart,
onEnd,
duration,
]);
React.useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (digitEffect === "none") {
if (ref.current) ref.current.textContent = formatValue(latest);
} else if (digitEffect !== "slide") {
setChars(formatValue(latest).split(""));
}
});
return () => unsubscribe();
}, [springValue, formatValue, digitEffect]);
const countingUp = direction === "up";
if (digitEffect === "slide") {
const targetStr = formatValue(direction === "down" ? from : to);
const digits: number[] = [];
const structure: Array<{
type: "digit" | "sep";
char?: string;
placeIdx?: number;
}> = [];
let digitCount = 0;
for (const ch of targetStr) {
if (/\d/.test(ch)) digitCount++;
}
let d = 0;
for (const ch of targetStr) {
if (/\d/.test(ch)) {
const placeFromRight = digitCount - 1 - d;
structure.push({ type: "digit", placeIdx: placeFromRight });
d++;
} else {
structure.push({ type: "sep", char: ch });
}
}
return (
<span
ref={ref}
className={cn("inline-flex items-center", className)}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{structure.map((item, i) =>
item.type === "sep" ? (
<span key={i}>{item.char}</span>
) : (
<OdometerDigit
key={i}
springValue={springValue}
place={Math.pow(10, item.placeIdx!)}
/>
),
)}
</span>
);
}
if (digitEffect === "none") {
return <span ref={ref} className={cn(className)} />;
}
return (
<span ref={ref} className={cn("inline-flex items-center", className)}>
{chars.map((char, i) => (
<CharSlot
key={i}
char={char}
charKey={`${i}-${char}`}
effect={digitEffect as Exclude<DigitEffect, "none" | "slide">}
countingUp={countingUp}
/>
))}
</span>
);
}
export { CountUp, type CountUpProps, type DigitEffect };Update the import paths to match your project setup.
Usage
import { CountUp } from "@/components/unlumen-ui/components/effects/count-up";
<CountUp to={1000000} separator="," className="text-4xl font-bold" />;API Reference
CountUp
| Prop | Type | Default |
|---|---|---|
to | number | - |
from? | number | 0 |
direction? | "up" | "down" | "up" |
duration? | number | 2 |
delay? | number | 0 |
separator? | string | "" |
startWhen? | boolean | true |
onStart? | () => void | - |
onEnd? | () => void | - |
className? | string | - |