Skip to content
goodtabs UI
BrowseBrowse components

Sticky stack

Cards stick and pile over each other, the one underneath easing back as the next lands.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/sticky-stack.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/sticky-stack

Four cards that stick in turn. Each one under the top card scales back and dims a little.

Usage

import { StickyStack, StickyStackCard } from "@/components/ui/sticky-stack"

<StickyStack aria-label="How it works">
  <StickyStackCard eyebrow="Step 01" title="Plan" description="One line on the step.">
    <img src="/plan.jpg" alt="" className="absolute inset-0 size-full object-cover" />
  </StickyStackCard>
</StickyStack>

Props

PropTypeDefaultDescription
topnumber64Distance from the top of the window to the first card, in px.
peeknumber12How much of each card stays visible above the next, in px.
eyebrowstringShort mono label on StickyStackCard.
titleReactNodeThe card's heading, required on StickyStackCard.
descriptionReactNodeOne grey line under the title.
childrenReactNodeThe visual on the right of the card, positioned to fill it.

Source

sticky-stack/sticky-stack.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const MOTION = "(prefers-reduced-motion: no-preference)";

/** How far the top card dims the one under it, capped so a deep card never goes blank. */
const SHADE = 0.32;

type StickyStackProps = React.ComponentProps<"ol"> & {
  /** Distance from the top of the window to the first card, in px. */
  top?: number;
  /** How much of each card stays visible above the next, in px. */
  peek?: number;
};

/**
 * Cards stick and pile over each other. The stack is plain position sticky and works with no script.
 * GSAP only adds the scale and shade on the cards underneath, so the deck reads as depth.
 * The list ends in a spacer, not padding: sticky stops at the content box, so the last card needs
 * room inside it to land and hold.
 */
function StickyStack({ top = 64, peek = 12, className, children, ...props }: StickyStackProps) {
  const root = React.useRef<HTMLOListElement>(null);
  const cards = React.Children.toArray(children);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(MOTION, () => {
        const slots = gsap.utils.toArray<HTMLElement>("[data-slot=sticky-stack-slot]");
        const last = slots.length - 1;
        if (last < 1) return;
        slots.slice(0, -1).forEach((slot, i) => {
          const depth = last - i;
          const timeline = gsap.timeline({
            defaults: { ease: "none" },
            scrollTrigger: {
              trigger: slot,
              start: `top ${top + i * peek}px`,
              endTrigger: slots[last],
              end: `top ${top + last * peek}px`,
              scrub: true,
              invalidateOnRefresh: true,
            },
          });
          // Inset per level equals peek, so the corners of the deck sit on a 45 degree line at any width.
          timeline.to(slot, { scale: () => 1 - (2 * peek * depth) / slot.offsetWidth }, 0);
          const shade = slot.querySelector("[data-slot=sticky-stack-shade]");
          if (shade) timeline.to(shade, { opacity: Math.min(SHADE, depth * 0.12) }, 0);
        });
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [top, peek, cards.length] },
  );

  return (
    <ol
      ref={root}
      data-slot="sticky-stack"
      className={cn("m-0 flex list-none flex-col gap-[18dvh] p-0 after:block after:h-[20dvh]", className)}
      {...props}
    >
      {cards.map((card, i) => (
        <li key={i} data-slot="sticky-stack-slot" className="sticky origin-top" style={{ top: top + i * peek }}>
          {card}
        </li>
      ))}
    </ol>
  );
}

type StickyStackCardProps = Omit<React.ComponentProps<"article">, "title"> & {
  /** Short mono label above the title, such as a step number. */
  eyebrow?: string;
  title: React.ReactNode;
  description?: React.ReactNode;
};

/** One card: copy on the left, whatever you pass as children bleeding to the right edge. */
function StickyStackCard({ eyebrow, title, description, className, children, ...props }: StickyStackCardProps) {
  return (
    <article
      data-slot="sticky-stack-card"
      className={cn(
        "surface-shine relative grid min-h-[62dvh] grid-rows-[auto_1fr] overflow-hidden rounded-lg border bg-card md:min-h-[66dvh] md:grid-cols-2 md:grid-rows-none",
        className,
      )}
      {...props}
    >
      <div className="flex min-w-0 flex-col justify-between gap-8 p-6 md:p-10">
        {eyebrow ? (
          <p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{eyebrow}</p>
        ) : null}
        <div>
          <h3 className="m-0 text-3xl leading-none font-medium tracking-tight md:text-5xl">{title}</h3>
          {description ? (
            <p className="mt-4 mb-0 max-w-md text-base text-muted-foreground md:text-lg">{description}</p>
          ) : null}
        </div>
      </div>
      <div className="relative min-h-52 min-w-0 md:min-h-0">{children}</div>
      <div
        data-slot="sticky-stack-shade"
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-background opacity-0"
      />
    </article>
  );
}

export { StickyStack, StickyStackCard };

For AI agents

Hand your agent this page, the markdown at /components/sticky-stack/prompt.md, or the Copy AI prompt button above.

When to use

  • Three to five ideas that build on each other
  • A feature tour where each card deserves the full window
  • A process you want to leave piled up, as a sense of progress

When not to use

  • For content people need to compare: a grid keeps everything visible
  • Cards taller than the window, because the bottom gets covered before it is read
  • Inside any overflow container, which turns position sticky off

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • The stack is plain CSS sticky, so it works with no script and under reduced motion; GSAP adds only the scale and the shade
  • No ancestor may set overflow hidden, auto or scroll, or sticky stops working
  • Set top to the height of any fixed header. The list ends in an after spacer so the last card can land and hold; padding cannot do this, because sticky stops at the content box

The maths

  • Each card sticks at top + i × peek, so 64 + 3 × 12 = 100px for the fourth card.
  • Scale is 1 - 2 × peek × depth / width, so each level insets 12px a side and the deck corners sit on a 45 degree line at any width.