Count-up Stats Band
Original · freeA metrics band whose numbers count up from zero the moment it scrolls into view.
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"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
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 quoteSimilar components
Browse all →
Scroll Progress Reveal
OriginalA 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
OriginalA layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

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

Horizontal Scroll Gallery
OriginalVertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

Scroll Reveal Timeline
OriginalA vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

Clip-path Image Reveal
OriginalPanels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

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

Scroll Text Highlight
OriginalA statement whose words light up one by one as you scroll through the section.

Scroll Zoom Hero
OriginalA hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

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

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

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

