Count Up

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éo
Loading...
demo-count-up.tsx
"use client";

import { CountUp, type DigitEffect } from "@/components/unlumen-ui/primitives/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:

components/unlumen-ui/primitives/count-up.tsx
"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

PropTypeDefaultDescription
tonumber-Target number to count to.
from?number0Starting number.
direction?"up" | "down""up"Count direction.
duration?number2Animation duration in seconds.
delay?number0Delay before animation starts, in seconds.
separator?string""Thousands separator character (e.g. "," or ".").
startWhen?booleantrueConditionally start the animation.
onStart?() => void-Callback fired when the animation starts.
onEnd?() => void-Callback fired when the animation ends.
className?string-Class applied to the span element.

Built by Léo

Last updated: 5/14/2026

On this page

Need beautifully designed components ready to go?

If you liked the docs, you'll love the components.