Number Counter
Animated number counter for React. Rolling digits, circular progress, and stats counters. Perfect for dashboards, pricing pages, and landing pages.
Last updated on
Basic Counter
$0.00Large Number
+0import { NumberCounter } from "@/components/ui/number-counter";
export function NumberCounterDemo() {
return (
<div className="flex w-full flex-col items-center justify-center gap-8 py-20">
<div className="text-center">
<h3 className="mb-2 font-medium text-muted-foreground text-sm">
Basic Counter
</h3>
<NumberCounter
value={1234.56}
decimals={2}
prefix="$"
className="font-bold text-4xl"
/>
</div>
<div className="text-center">
<h3 className="mb-2 font-medium text-muted-foreground text-sm">
Large Number
</h3>
<NumberCounter
value={1000000}
prefix="+"
className="font-bold text-4xl text-primary"
/>
</div>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/number-counter"Manual
Install the following dependencies:
npm install motionCopy and paste the following code into your project.
import { motion, useInView, useSpring, useTransform } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
type EasingType =
| "linear"
| "easeOut"
| "easeIn"
| "easeInOut"
| "spring"
| "bounce";
interface NumberCounterProps {
value: number;
from?: number;
duration?: number;
delay?: number;
decimals?: number;
separator?: string;
decimalSeparator?: string;
prefix?: string;
suffix?: string;
easing?: EasingType;
className?: string;
once?: boolean;
formatFn?: (value: number) => string;
}
// Easing functions for CSS-based animation
const easingFunctions: Record<EasingType, number[]> = {
linear: [0, 0, 1, 1],
easeOut: [0.16, 1, 0.3, 1],
easeIn: [0.7, 0, 0.84, 0],
easeInOut: [0.65, 0, 0.35, 1],
spring: [0.34, 1.56, 0.64, 1],
bounce: [0.68, -0.55, 0.27, 1.55],
};
// Format number with separators
const formatNumber = (
value: number,
decimals: number,
separator: string,
decimalSeparator: string,
): string => {
const fixed = value.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const formattedInt = (intPart || "").replace(
/\B(?=(\d{3})+(?!\d))/g,
separator,
);
return decPart
? `${formattedInt}${decimalSeparator}${decPart}`
: formattedInt;
};
// Animated digit component for rolling effect
const AnimatedDigit = ({
digit,
delay = 0,
}: {
digit: string;
delay?: number;
}) => {
const isNumber = /\d/.test(digit);
if (!isNumber) {
return <span className="inline-block">{digit}</span>;
}
const num = parseInt(digit, 10);
return (
<span className="relative inline-block h-[1em] w-[0.6em] overflow-hidden">
<motion.span
className="absolute top-0 left-0 flex w-full flex-col items-center"
initial={{ y: "-100%" }}
animate={{ y: `${-num * 10}%` }}
transition={{
duration: 0.5,
delay,
ease: [0.16, 1, 0.3, 1],
}}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<span key={n} className="h-[1em] leading-none">
{n}
</span>
))}
</motion.span>
</span>
);
};
// Spring-based counter using useSpring
const SpringCounter = ({
value,
from = 0,
decimals = 0,
separator = ",",
decimalSeparator = ".",
prefix = "",
suffix = "",
className,
once = true,
formatFn,
duration = 2,
delay = 0,
}: NumberCounterProps) => {
const ref = React.useRef(null);
const isInView = useInView(ref, { once });
const [displayValue, setDisplayValue] = React.useState(from);
const springValue = useSpring(from, {
stiffness: 100,
damping: 30,
duration: duration * 1000,
});
React.useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
springValue.set(value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [isInView, value, springValue, delay]);
React.useEffect(() => {
const unsubscribe = springValue.on("change", (latest) => {
setDisplayValue(latest);
});
return unsubscribe;
}, [springValue]);
const formatted = formatFn
? formatFn(displayValue)
: formatNumber(displayValue, decimals, separator, decimalSeparator);
return (
<span ref={ref} className={className}>
{prefix}
{formatted}
{suffix}
</span>
);
};
// Main NumberCounter component
const NumberCounter = React.forwardRef<HTMLSpanElement, NumberCounterProps>(
(
{
value,
from = 0,
duration = 2,
delay = 0,
decimals = 0,
separator = ",",
decimalSeparator = ".",
prefix = "",
suffix = "",
easing = "easeOut",
className,
once = true,
formatFn,
},
ref,
) => {
const containerRef = React.useRef<HTMLSpanElement>(null);
const isInView = useInView(containerRef, { once });
const [count, setCount] = React.useState(from);
React.useEffect(() => {
if (!isInView) return;
const startTime = Date.now() + delay * 1000;
const endTime = startTime + duration * 1000;
// Cubic bezier easing function
const bezier = easingFunctions[easing] || easingFunctions.linear;
const cubicBezier = (t: number): number => {
const [x1 = 0, y1 = 0, x2 = 1, y2 = 1] = bezier;
// Simplified cubic bezier approximation
const cx = 3 * x1;
const bx = 3 * (x2 - x1) - cx;
const ax = 1 - cx - bx;
const cy = 3 * y1;
const by = 3 * (y2 - y1) - cy;
const ay = 1 - cy - by;
const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
// Newton-Raphson iteration for finding t given x
let x = t;
for (let i = 0; i < 8; i++) {
const currentX = sampleCurveX(x) - t;
if (Math.abs(currentX) < 0.0001) break;
const dx = (3 * ax * x + 2 * bx) * x + cx;
if (Math.abs(dx) < 0.0001) break;
x -= currentX / dx;
}
return sampleCurveY(x);
};
let animationFrame: number;
const animate = () => {
const now = Date.now();
if (now < startTime) {
animationFrame = requestAnimationFrame(animate);
return;
}
if (now >= endTime) {
setCount(value);
return;
}
const elapsed = now - startTime;
const total = endTime - startTime;
const progress = elapsed / total;
const easedProgress = cubicBezier(progress);
const currentValue = from + (value - from) * easedProgress;
setCount(currentValue);
animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [isInView, value, from, duration, delay, easing]);
const formatted = formatFn
? formatFn(count)
: formatNumber(count, decimals, separator, decimalSeparator);
return (
<span ref={containerRef} className={cn("tabular-nums", className)}>
{prefix}
<span ref={ref}>{formatted}</span>
{suffix}
</span>
);
},
);
NumberCounter.displayName = "NumberCounter";
// Rolling digits counter
interface RollingCounterProps {
value: number;
className?: string;
prefix?: string;
suffix?: string;
separator?: string;
}
const RollingCounter = React.forwardRef<HTMLSpanElement, RollingCounterProps>(
({ value, className, prefix = "", suffix = "", separator = "," }, ref) => {
const containerRef = React.useRef<HTMLSpanElement>(null);
const isInView = useInView(containerRef, { once: true });
const formattedValue = formatNumber(value, 0, separator, ".");
const digits = formattedValue.split("");
return (
<span
ref={containerRef}
className={cn("inline-flex tabular-nums", className)}
>
{prefix && <span>{prefix}</span>}
<span ref={ref} className="inline-flex">
{isInView &&
digits.map((digit, index) => (
<AnimatedDigit
key={`${index}-${digit}`}
digit={digit}
delay={index * 0.05}
/>
))}
</span>
{suffix && <span>{suffix}</span>}
</span>
);
},
);
RollingCounter.displayName = "RollingCounter";
// Percentage counter with circular progress
interface CircularCounterProps {
value: number;
size?: number;
strokeWidth?: number;
className?: string;
duration?: number;
color?: string;
trackColor?: string;
}
const CircularCounter = React.forwardRef<HTMLDivElement, CircularCounterProps>(
(
{
value,
size = 120,
strokeWidth = 8,
className,
duration = 2,
color = "hsl(var(--primary))",
trackColor = "hsl(var(--muted))",
},
ref,
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { once: true });
const springValue = useSpring(0, {
duration: duration * 1000,
bounce: 0,
});
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = useTransform(springValue, [0, 100], [circumference, 0]);
const rounded = useTransform(springValue, (latest) => Math.round(latest));
React.useEffect(() => {
if (isInView) {
springValue.set(value);
}
}, [isInView, value, springValue]);
return (
<div
ref={containerRef}
className={cn(
"relative inline-flex items-center justify-center",
className,
)}
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
style={{ strokeDashoffset: offset }}
/>
</svg>
<div
ref={ref}
className="absolute inset-0 flex items-center justify-center"
>
<span
className="font-bold tabular-nums leading-none"
style={{ fontSize: size * 0.22 }}
>
<motion.span>{rounded}</motion.span>%
</span>
</div>
</div>
);
},
);
CircularCounter.displayName = "CircularCounter";
// Compact counter for stats
interface StatCounterProps {
value: number;
label: string;
prefix?: string;
suffix?: string;
decimals?: number;
className?: string;
}
const StatCounter = React.forwardRef<HTMLDivElement, StatCounterProps>(
(
{ value, label, prefix = "", suffix = "", decimals = 0, className },
ref,
) => {
return (
<div ref={ref} className={cn("text-center", className)}>
<NumberCounter
value={value}
prefix={prefix}
suffix={suffix}
decimals={decimals}
duration={2.5}
easing="easeOut"
className="font-bold text-4xl text-foreground"
/>
<p className="mt-2 text-muted-foreground text-sm">{label}</p>
</div>
);
},
);
StatCounter.displayName = "StatCounter";
export {
CircularCounter,
formatNumber,
NumberCounter,
RollingCounter,
SpringCounter,
StatCounter,
};
export type {
CircularCounterProps,
EasingType,
NumberCounterProps,
RollingCounterProps,
StatCounterProps,
};Features
- Multiple Styles: Includes standard counter, rolling digits, circular progress, and stat cards.
- Customizable: Adjust duration, easing, formatting, prefixes, and suffixes.
- Performance: Optimized animations using Framer Motion and requestAnimationFrame.
- Accessible: Uses tabular-nums for consistent width and proper ARIA support.
Examples
Rolling Counter
A classic odometer-style rolling counter effect.
Rolling Counter
$import { RollingCounter } from "@/components/ui/number-counter";
export function RollingCounterDemo() {
return (
<div className="flex w-full flex-col items-center justify-center gap-8 py-20">
<div className="text-center">
<h3 className="mb-4 font-medium text-muted-foreground text-sm">
Rolling Counter
</h3>
<RollingCounter
value={98765}
prefix="$"
className="font-black text-6xl tracking-tight"
/>
</div>
</div>
);
}Circular Counter
A circular progress indicator with an animated percentage counter.
import { CircularCounter } from "@/components/ui/number-counter";
export function CircularCounterDemo() {
return (
<div className="flex w-full flex-wrap items-center justify-center gap-16 rounded-xl border border-neutral-800 bg-neutral-950 py-12">
<div className="flex flex-col items-center gap-4">
<CircularCounter
value={75}
size={160}
strokeWidth={12}
className="text-white"
color="white"
trackColor="rgba(255,255,255,0.1)"
/>
<span className="font-medium text-neutral-400 text-sm">
Project Completion
</span>
</div>
<div className="flex flex-col items-center gap-4">
<CircularCounter
value={92}
size={120}
strokeWidth={8}
className="text-white"
color="#22c55e" // green-500
trackColor="rgba(255,255,255,0.1)"
/>
<span className="font-medium text-neutral-400 text-sm">
Success Rate
</span>
</div>
</div>
);
}Stat Counter
A pre-styled component perfect for displaying statistics and metrics.
import { StatCounter } from "@/components/ui/number-counter";
export function StatCounterDemo() {
return (
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-8 py-20 md:grid-cols-3">
<StatCounter
value={15000}
label="Active Users"
prefix="+"
className="rounded-xl bg-secondary/20 p-6"
/>
<StatCounter
value={98.5}
label="Satisfaction Rate"
suffix="%"
decimals={1}
className="rounded-xl bg-secondary/20 p-6"
/>
<StatCounter
value={2500}
label="Revenue"
prefix="$"
className="rounded-xl bg-secondary/20 p-6"
/>
</div>
);
}API Reference
NumberCounter
The core counter component with easing support.
Prop
Type
RollingCounter
Odometer-style rolling digit counter.
Prop
Type
CircularCounter
Circular progress counter.
Prop
Type
StatCounter
Statistical display component.
Prop
Type
How is this guide?
Highlight Text
Animated text highlighter for React. SVG underlines, boxes, circles, and marker effects. Draw attention to key words with hand-drawn style animations.
Rotate Text
Word rotation animation for React. Smooth vertical flip transitions with spring physics. Great for hero headlines and dynamic taglines.