Web InnoventixFreeCode

Count-up Stats Band

Original · free

A metrics band whose numbers count up from zero the moment it scrolls into view.

byWeb InnoventixReact + Tailwind
scrollstatscount-up
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scroll-count-up-stats.json
scroll-count-up-stats.tsx
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useInView } from "motion/react";

type Stat = {
  value: number;
  decimals: number;
  suffix: string;
  prefix: string;
  label: string;
  detail: string;
  icon: ReactNode;
  accent: string;
  glow: string;
};

const STATS: Stat[] = [
  {
    value: 99.98,
    decimals: 2,
    suffix: "%",
    prefix: "",
    label: "Uptime delivered",
    detail: "Across 40+ production deployments last year",
    accent: "from-emerald-400 to-emerald-600",
    glow: "bg-emerald-400/30 dark:bg-emerald-400/20",
    icon: (
      <path d="M12 3l7.5 4.2v4.8c0 4.4-3.1 8.2-7.5 9.2-4.4-1-7.5-4.8-7.5-9.2V7.2L12 3zm-1 11.6l4.6-4.6-1.4-1.4L11 11.8 9.2 10l-1.4 1.4L11 14.6z" />
    ),
  },
  {
    value: 128,
    decimals: 0,
    suffix: "k",
    prefix: "",
    label: "Users served",
    detail: "Monthly active across shipped client apps",
    accent: "from-sky-400 to-indigo-600",
    glow: "bg-sky-400/30 dark:bg-sky-400/20",
    icon: (
      <path d="M12 12a4 4 0 100-8 4 4 0 000 8zm-8 8a8 8 0 0116 0v1H4v-1z" />
    ),
  },
  {
    value: 3.4,
    decimals: 1,
    suffix: "x",
    prefix: "",
    label: "Faster launches",
    detail: "Median time-to-ship vs. the previous stack",
    accent: "from-violet-400 to-fuchsia-600",
    glow: "bg-fuchsia-400/30 dark:bg-fuchsia-400/20",
    icon: (
      <path d="M13 2L4.5 13.5H11l-1 8.5L19.5 10H13l0-8z" />
    ),
  },
  {
    value: 180,
    decimals: 0,
    suffix: "ms",
    prefix: "",
    label: "Median response",
    detail: "P75 API latency measured at the edge",
    accent: "from-amber-400 to-orange-600",
    glow: "bg-amber-400/30 dark:bg-amber-400/20",
    icon: (
      <path d="M12 22a9 9 0 110-18 9 9 0 010 18zm1-9V7h-2v8h6v-2h-4z" />
    ),
  },
];

function easeOutExpo(t: number): number {
  return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}

function formatNumber(n: number, decimals: number): string {
  return n.toLocaleString("en-US", {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });
}

function CountUp({
  target,
  decimals,
  duration,
  play,
}: {
  target: number;
  decimals: number;
  duration: number;
  play: boolean;
}): ReactNode {
  const [display, setDisplay] = useState<number>(0);
  const frameRef = useRef<number | null>(null);

  useEffect(() => {
    if (!play) return;

    const reduce =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (reduce) {
      setDisplay(target);
      return;
    }

    let start: number | null = null;

    const step = (now: number): void => {
      if (start === null) start = now;
      const elapsed = now - start;
      const progress = Math.min(elapsed / duration, 1);
      setDisplay(target * easeOutExpo(progress));
      if (progress < 1) {
        frameRef.current = requestAnimationFrame(step);
      }
    };

    frameRef.current = requestAnimationFrame(step);

    return () => {
      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
    };
  }, [play, target, duration]);

  return <>{formatNumber(display, decimals)}</>;
}

function StatCard({ stat, index }: { stat: Stat; index: number }): ReactNode {
  const cardRef = useRef<HTMLDivElement | null>(null);
  const inView = useInView(cardRef, { once: true, amount: 0.4 });

  return (
    <motion.div
      ref={cardRef}
      initial={{ opacity: 0, y: 28 }}
      animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 28 }}
      transition={{ duration: 0.6, delay: index * 0.12, ease: [0.22, 1, 0.36, 1] }}
      className="group relative flex flex-col overflow-hidden rounded-2xl border border-slate-200/80 bg-white/70 p-6 backdrop-blur-sm transition-colors duration-300 hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-white/20 sm:p-8"
    >
      <div
        aria-hidden="true"
        className={`pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full blur-2xl transition-opacity duration-500 ${stat.glow} opacity-0 group-hover:opacity-100`}
      />

      <div
        aria-hidden="true"
        className={`mb-6 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br ${stat.accent} text-white shadow-sm`}
      >
        <svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
          {stat.icon}
        </svg>
      </div>

      <div className="flex items-baseline gap-0.5">
        <span className="bg-gradient-to-br from-slate-900 to-slate-600 bg-clip-text text-4xl font-bold tabular-nums tracking-tight text-transparent dark:from-white dark:to-slate-300 sm:text-5xl">
          {stat.prefix}
          <CountUp
            target={stat.value}
            decimals={stat.decimals}
            duration={1800 + index * 220}
            play={inView}
          />
        </span>
        <span
          className={`bg-gradient-to-br bg-clip-text text-2xl font-semibold text-transparent sm:text-3xl ${stat.accent}`}
        >
          {stat.suffix}
        </span>
      </div>

      <div
        aria-hidden="true"
        className="relative mt-4 h-[3px] w-full overflow-hidden rounded-full bg-slate-200/70 dark:bg-white/10"
      >
        <motion.span
          className={`absolute inset-y-0 left-0 rounded-full bg-gradient-to-r ${stat.accent}`}
          initial={{ width: "0%" }}
          animate={inView ? { width: "100%" } : { width: "0%" }}
          transition={{
            duration: 1.6,
            delay: 0.2 + index * 0.12,
            ease: [0.16, 1, 0.3, 1],
          }}
        />
      </div>

      <h3 className="mt-5 text-base font-semibold text-slate-900 dark:text-white">
        {stat.label}
      </h3>
      <p className="mt-1.5 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
        {stat.detail}
      </p>
    </motion.div>
  );
}

export default function ScrollCountUpStats(): ReactNode {
  const headingRef = useRef<HTMLDivElement | null>(null);
  const headingInView = useInView(headingRef, { once: true, amount: 0.5 });

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 dark:bg-slate-950 sm:px-8 sm:py-32">
      <style>{`
        @keyframes szh-drift {
          0% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(3%, -4%, 0) scale(1.08); }
          100% { transform: translate3d(0, 0, 0) scale(1); }
        }
        .szh-orb-a { animation: szh-drift 16s ease-in-out infinite; }
        .szh-orb-b { animation: szh-drift 20s ease-in-out infinite reverse; }
        @media (prefers-reduced-motion: reduce) {
          .szh-orb-a, .szh-orb-b { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 overflow-hidden"
      >
        <div className="szh-orb-a absolute -left-24 top-0 h-96 w-96 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="szh-orb-b absolute -right-24 bottom-0 h-96 w-96 rounded-full bg-fuchsia-300/40 blur-3xl dark:bg-fuchsia-600/20" />
        <div
          className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
          style={{
            backgroundImage:
              "linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
            backgroundSize: "56px 56px",
            color: "rgb(100 116 139)",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-6xl">
        <motion.div
          ref={headingRef}
          initial={{ opacity: 0, y: 24 }}
          animate={headingInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
          transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
          className="mx-auto max-w-2xl text-center"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/60 px-4 py-1.5 text-xs font-medium uppercase tracking-wider text-slate-600 backdrop-blur-sm dark:border-white/10 dark:bg-white/[0.04] dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Measured, not marketed
          </span>
          <h2 className="mt-6 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl md:text-5xl">
            Numbers that hold up under load
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400 sm:text-lg">
            Every figure below is pulled from live production, not a pitch deck.
            Scroll in and watch them settle to the truth.
          </p>
        </motion.div>

        <div className="mt-16 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
          {STATS.map((stat, i) => (
            <StatCard key={stat.label} stat={stat} index={i} />
          ))}
        </div>

        <motion.p
          initial={{ opacity: 0 }}
          animate={headingInView ? { opacity: 1 } : { opacity: 0 }}
          transition={{ duration: 0.8, delay: 0.6 }}
          className="mt-12 text-center text-sm text-slate-500 dark:text-slate-500"
        >
          Figures reflect the trailing 12 months across active engagements.
        </motion.p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →
Scroll Progress Reveal

Scroll Progress Reveal

Original

A section that reveals its content in sequence as you scroll, with a sticky scroll-progress bar showing how far through it you are.

Parallax Layers

Parallax Layers

Original

A layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

Pinned Scroll Steps

Pinned Scroll Steps

Original

A pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.

Horizontal Scroll Gallery

Horizontal Scroll Gallery

Original

Vertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

Scroll Reveal Timeline

Scroll Reveal Timeline

Original

A vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

Clip-path Image Reveal

Clip-path Image Reveal

Original

Panels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

Stacking Cards

Stacking Cards

Original

A deck of cards that pin and scale as you scroll, each settling behind the next.

Scroll Text Highlight

Scroll Text Highlight

Original

A statement whose words light up one by one as you scroll through the section.

Scroll Zoom Hero

Scroll Zoom Hero

Original

A hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

Spotlight Hero

Spotlight Hero

Original

A centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero

Split Hero

Original

A two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero

Gradient Spotlight Hero

Original

A minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.