ComponentsText Animations

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

Edit on GitHub
Open in

Basic Counter

$0.00

Large Number

+0
import { 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 motion

Copy 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.

Open in

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?

On this page