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
| Prop | Type | Default | Description |
|---|---|---|---|
| top | number | 64 | Distance from the top of the window to the first card, in px. |
| peek | number | 12 | How much of each card stays visible above the next, in px. |
| eyebrow | string | Short mono label on StickyStackCard. | |
| title | ReactNode | The card's heading, required on StickyStackCard. | |
| description | ReactNode | One grey line under the title. | |
| children | ReactNode | The 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.