Web InnoventixFreeCode

Stacking Cards

Original · free

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

byWeb InnoventixReact + Tailwind
scrollstickystack
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-stacking-cards.json
scroll-stacking-cards.tsx
"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

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.

Count-up Stats Band

Count-up Stats Band

Original

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

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.

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.