Components

Number Counter

A collection of animated number counters including rolling digits, circular progress, and statistical counters.

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