Count Up
Animated number counter supporting formatting and decimals.
pnpm dlx shadcn@latest add @leo/count-upProps
| Name | Type | Default | Description |
|---|---|---|---|
| className | string | undefined | ||
| delay | number | undefined | 0 | |
| direction | undefined | "up" | "down" | up | |
| duration | number | undefined | 2 | |
| from | number | undefined | 0 | |
| onEnd | (() => void) | undefined | - | |
| onStart | (() => void) | undefined | - | |
| separator | string | undefined | ||
| startWhen | boolean | undefined | true | |
| to* | number | - |
Dependencies
- motion@^12.23.12
Source
"use client";
/*
* Ported from react-bits (MIT, © David Haz): src/ts-tailwind/TextAnimations/CountUp/CountUp.tsx @ 9d0270d
*
* MIT License
*
* Copyright (c) 2026 David Haz
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { useInView, useMotionValue, useSpring } from "motion/react";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export interface CountUpProps {
to: number;
from?: number;
direction?: "up" | "down";
delay?: number;
duration?: number;
className?: string;
startWhen?: boolean;
separator?: string;
onStart?: () => void;
onEnd?: () => void;
}
export function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2,
className = "",
startWhen = true,
separator = "",
onStart,
onEnd,
}: CountUpProps) {
const ref = 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 decimals = str.split(".")[1];
if (parseInt(decimals, 10) !== 0) {
return decimals.length;
}
}
return 0;
};
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
const formatValue = useCallback(
(latest: number) => {
const hasDecimals = maxDecimals > 0;
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
},
[maxDecimals, separator],
);
useEffect(() => {
if (ref.current) {
ref.current.textContent = formatValue(direction === "down" ? to : from);
}
}, [from, to, direction, formatValue]);
useEffect(() => {
if (isInView && startWhen) {
if (typeof onStart === "function") {
onStart();
}
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
if (typeof onEnd === "function") {
onEnd();
}
},
delay * 1000 + duration * 1000,
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (ref.current) {
ref.current.textContent = formatValue(latest);
}
});
return () => unsubscribe();
}, [springValue, formatValue]);
return <span className={cn(className)} ref={ref} />;
}
export default CountUp;