Stacking Cards
Original · freeA deck of cards that pin and scale as you scroll, each settling behind the next.
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-stacking-cards.json"use client";
import { useRef, type ReactNode } from "react";
import { motion, useScroll, useTransform, useSpring, type MotionValue } from "motion/react";
type CardData = {
index: string;
eyebrow: string;
title: string;
body: string;
metric: string;
metricLabel: string;
accent: string;
glow: string;
chip: string;
chipText: string;
icon: ReactNode;
};
const CARDS: CardData[] = [
{
index: "01",
eyebrow: "Discover",
title: "Map the terrain before you build",
body: "We audit the whole surface — traffic, intent, friction, and the gaps competitors leave open — so every later decision rests on evidence instead of instinct.",
metric: "48h",
metricLabel: "to first insight",
accent: "from-indigo-500 to-violet-600",
glow: "bg-indigo-400/40 dark:bg-indigo-500/30",
chip: "bg-indigo-50 dark:bg-indigo-950/60 border-indigo-200 dark:border-indigo-800/70",
chipText: "text-indigo-700 dark:text-indigo-300",
icon: (
<path
d="M4 7l6-3 6 3 4-2v14l-4 2-6-3-6 3V5zm6-3v14m6-11v14"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
},
{
index: "02",
eyebrow: "Design",
title: "Shape a system, not a screen",
body: "Tokens, motion, and layout rules become one language. Components inherit rather than repeat, so the tenth page ships as fast as the first and stays consistent.",
metric: "1×",
metricLabel: "source of truth",
accent: "from-sky-500 to-cyan-600",
glow: "bg-sky-400/40 dark:bg-sky-500/30",
chip: "bg-sky-50 dark:bg-sky-950/60 border-sky-200 dark:border-sky-800/70",
chipText: "text-sky-700 dark:text-sky-300",
icon: (
<path
d="M12 3l2.2 5.6L20 10l-4.4 3.4L17 20l-5-3-5 3 1.4-6.6L4 10l5.8-1.4L12 3z"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
},
{
index: "03",
eyebrow: "Build",
title: "Ship code that survives scale",
body: "Strict types, zero dead abstractions, and animation that respects the machine. What we hand over reads clearly at 3am and bends without breaking six months later.",
metric: "99",
metricLabel: "Lighthouse median",
accent: "from-emerald-500 to-teal-600",
glow: "bg-emerald-400/40 dark:bg-emerald-500/30",
chip: "bg-emerald-50 dark:bg-emerald-950/60 border-emerald-200 dark:border-emerald-800/70",
chipText: "text-emerald-700 dark:text-emerald-300",
icon: (
<path
d="M9 8l-4 4 4 4m6-8l4 4-4 4M13.5 5l-3 14"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
},
{
index: "04",
eyebrow: "Grow",
title: "Compound the wins you earned",
body: "Every release feeds the next. We measure what moved, retire what didn't, and reinvest the margin — turning a launch into a slope that keeps climbing.",
metric: "3.4×",
metricLabel: "12-month return",
accent: "from-fuchsia-500 to-rose-600",
glow: "bg-fuchsia-400/40 dark:bg-fuchsia-500/30",
chip: "bg-fuchsia-50 dark:bg-fuchsia-950/60 border-fuchsia-200 dark:border-fuchsia-800/70",
chipText: "text-fuchsia-700 dark:text-fuchsia-300",
icon: (
<path
d="M4 19h16M6 19v-5m5 5V9m5 10V5M4 12l4-4 4 3 8-7"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
},
];
function StackedCard({
card,
i,
total,
progress,
}: {
card: CardData;
i: number;
total: number;
progress: MotionValue<number>;
}) {
// Each card owns a slice of the scroll timeline. As the NEXT card's slice
// begins, this card shrinks and dims slightly, settling into the deck.
const start = i / total;
const end = (i + 1) / total;
const rawScale = useTransform(progress, [start, end], [1, 0.9 - i * 0.008]);
const rawOpacity = useTransform(progress, [start, end], [1, 0.55]);
const rawBlur = useTransform(progress, [start, end], [0, 3]);
const scale = useSpring(rawScale, { stiffness: 220, damping: 32, mass: 0.6 });
const opacity = useSpring(rawOpacity, { stiffness: 220, damping: 32, mass: 0.6 });
const filter = useTransform(rawBlur, (b: number) => `blur(${b}px)`);
return (
<div className="sticky" style={{ top: `${96 + i * 28}px` }}>
<motion.article
style={{ scale, opacity, filter }}
className="relative mx-auto max-w-3xl overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 shadow-[0_20px_60px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/85 dark:shadow-[0_30px_80px_-24px_rgba(0,0,0,0.7)]"
>
{/* accent glow */}
<div
aria-hidden="true"
className={`pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full blur-3xl ${card.glow}`}
/>
{/* top accent bar */}
<div aria-hidden="true" className={`h-1.5 w-full bg-gradient-to-r ${card.accent}`} />
<div className="grid gap-8 p-8 sm:grid-cols-[1.4fr_1fr] sm:p-10">
<div className="relative">
<div className="mb-5 flex items-center gap-3">
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br ${card.accent} text-white shadow-lg`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="h-6 w-6">
{card.icon}
</svg>
</span>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${card.chip} ${card.chipText}`}
>
{card.eyebrow}
</span>
</div>
<h3 className="text-2xl font-semibold leading-tight tracking-tight text-slate-900 sm:text-[1.7rem] dark:text-white">
{card.title}
</h3>
<p className="mt-3 text-[0.975rem] leading-relaxed text-slate-600 dark:text-slate-300">
{card.body}
</p>
</div>
<div className="flex flex-col justify-between gap-6 border-slate-200/70 sm:border-l sm:pl-8 dark:border-white/10">
<div>
<div
className={`bg-gradient-to-br bg-clip-text text-5xl font-bold tracking-tight text-transparent ${card.accent}`}
>
{card.metric}
</div>
<div className="mt-1 text-sm font-medium text-slate-500 dark:text-slate-400">
{card.metricLabel}
</div>
</div>
<div className="flex items-center gap-2">
{CARDS.map((_, dot) => (
<span
key={dot}
aria-hidden="true"
className={`h-1.5 rounded-full transition-all ${
dot === i
? `w-8 bg-gradient-to-r ${card.accent}`
: "w-1.5 bg-slate-300 dark:bg-slate-700"
}`}
/>
))}
</div>
</div>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute right-6 bottom-5 text-6xl font-black tabular-nums text-slate-900/5 sm:text-7xl dark:text-white/5"
>
{card.index}
</div>
</motion.article>
</div>
);
}
export default function ScrollStackingCards() {
const trackRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: trackRef,
offset: ["start start", "end end"],
});
const barScale = useSpring(scrollYProgress, { stiffness: 200, damping: 40, mass: 0.5 });
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-50 py-24 dark:from-slate-950 dark:via-slate-950 dark:to-black">
<style>{`
@keyframes szh-drift {
0% { transform: translate3d(0,0,0) scale(1); opacity: .5; }
50% { transform: translate3d(2%, -3%, 0) scale(1.08); opacity: .8; }
100% { transform: translate3d(0,0,0) scale(1); opacity: .5; }
}
.szh-orb { animation: szh-drift 14s ease-in-out infinite; }
.szh-orb-2 { animation: szh-drift 18s ease-in-out infinite reverse; }
@media (prefers-reduced-motion: reduce) {
.szh-orb, .szh-orb-2 { animation: none !important; }
}
`}</style>
{/* ambient background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="szh-orb absolute -left-24 top-32 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20" />
<div className="szh-orb-2 absolute -right-24 top-1/2 h-80 w-80 rounded-full bg-fuchsia-400/20 blur-3xl dark:bg-fuchsia-600/20" />
<div
className="absolute inset-0 opacity-[0.4] dark:opacity-[0.25]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgb(148 163 184 / 0.35) 1px, transparent 0)",
backgroundSize: "28px 28px",
}}
/>
</div>
{/* header */}
<div className="relative mx-auto mb-8 max-w-3xl px-6 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-4 py-1.5 text-xs font-semibold uppercase tracking-wider text-slate-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
How we work
</span>
<h2 className="mt-5 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
Four moves, stacked into a system
</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
Scroll to walk the deck. Each phase pins, settles behind the next, and hands its
momentum forward — the way a real engagement should feel.
</p>
</div>
{/* scroll progress rail */}
<div className="relative mx-auto mb-4 h-1 max-w-3xl overflow-hidden rounded-full bg-slate-200/70 px-0 dark:bg-white/10">
<motion.div
style={{ scaleX: barScale }}
className="h-full w-full origin-left rounded-full bg-gradient-to-r from-indigo-500 via-sky-500 to-fuchsia-500"
/>
</div>
{/* stacking track — tall enough to drive the pinning */}
<div ref={trackRef} className="relative px-6 pb-[40vh]">
<div className="mx-auto flex max-w-3xl flex-col gap-[42vh]">
{CARDS.map((card, i) => (
<StackedCard
key={card.index}
card={card}
i={i}
total={CARDS.length}
progress={scrollYProgress}
/>
))}
</div>
</div>
{/* footer note */}
<div className="relative mx-auto max-w-3xl px-6 pt-4 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">
Every layer stays visible — nothing you scrolled past is truly gone.
</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.

Count-up Stats Band
OriginalA metrics band whose numbers count up from zero the moment it scrolls into view.

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

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.

