Stack Fly-Out Cards
Cards rise into a peeking stack, then fly off one by one as you scroll.
Customize
<StackFlyCards cards={cards} />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
// A deck of cards in a pinned section. As you scroll, the cards rise into a stack where each one
// peeks out under the one in front, then the front card flies off the top, the rest move up a
// place, and so on until only the last card is left. One scrubbed timeline does all of it, so
// scrolling back plays it in reverse.
if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);
export type StackFlyCard = {
/** The card's photo. */
src: string;
/** Describes the photo for screen readers. Empty means it's decoration. */
alt?: string;
/** The big serif title. */
title: string;
/** A short line under the title. */
lede?: string;
/** The paragraph near the bottom of the card. */
body: string;
/** Where the card's button goes. No href, no button. */
href?: string;
/** The button's label. */
cta?: string;
/** The card's colour. Defaults to a palette of five soft tones. */
background?: string;
/** The card's text colour. */
foreground?: string;
};
export type StackFlyCardsProps = {
/** The cards, front card first. Three to six read best. */
cards: StackFlyCard[];
/** How far each card in the stack peeks out under the one in front, in pixels. */
peek?: number;
/** How much smaller each card in the stack is than the one in front (0.05 is 5%). */
scaleStep?: number;
/** How far a card turns as it flies off, in degrees. */
flyRotate?: number;
/** How long the pinned section lasts: screens of scrolling per card. */
scrollLength?: number;
/** How long the cards take to catch up with the scrollbar, in seconds. 0 follows it exactly. */
smoothing?: number;
/** The scrolling element (an element or a selector). Leave it out to use the page. */
scroller?: HTMLElement | string | null;
/** The section's background. The cards' shadows look best on a dark one. */
background?: string;
/** The colour of the square behind each button's arrow. */
accent?: string;
/** The font of the card titles and numbers. Load it yourself (the demo loads Instrument Serif). */
titleFont?: string;
/** Content above the deck, like a heading. It sits over the cards as they fly past. */
children?: ReactNode;
/** Extra classes for the heading's box, e.g. "mix-blend-difference" so it inverts over the cards. */
headerClassName?: string;
/** Extra classes for the section. It's one screen tall and pins while the cards move. */
className?: string;
};
// The five tones from the original video, used in turn when a card has no background of its own
const tones = ["#d6ef4a", "#f4f4f2", "#6db5a8", "#e8d5c4", "#c8b8e8"];
// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function resolveScroller(scroller: StackFlyCardsProps["scroller"]): HTMLElement | null {
if (!scroller) return null;
if (typeof scroller === "string") {
const element = document.querySelector<HTMLElement>(scroller);
// Pinning against the page when a scroller was asked for looks broken in a confusing way, so say so
if (!element && typeof process !== "undefined" && process.env.NODE_ENV !== "production") console.warn("StackFlyCards: scroller not found", scroller);
return element;
}
return scroller;
}
export function StackFlyCards({
cards,
peek = 42,
scaleStep = 0.045,
flyRotate = 25,
scrollLength = 1,
smoothing = 0.6,
scroller = null,
background = "#121212",
accent = "#d6ef4a",
titleFont = '"Instrument Serif", "Times New Roman", Times, serif',
children,
headerClassName = "",
className = "",
}: StackFlyCardsProps) {
const sectionRef = useRef<HTMLElement>(null);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
// Where the scroller was, so rebuilding the timeline (a control changed) doesn't jump the page
const lastScroll = useRef(0);
const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
useGSAP(
() => {
const section = sectionRef.current;
const deck = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLElement => card !== null);
if (!section || deck.length === 0 || reducedMotion) return;
const scrollerElement = resolveScroller(scroller);
const viewportHeight = () => (scrollerElement ? scrollerElement.clientHeight : window.innerHeight);
// Where card i sits in the stack: lower down and a little smaller for every card in front of it
const pose = (i: number) => ({ y: i * peek, scale: 1 - i * scaleStep });
// In a scrolling element the section is as tall as that element, not the window
const fitHeight = () => {
if (scrollerElement) section.style.minHeight = `${scrollerElement.clientHeight}px`;
};
fitHeight();
ScrollTrigger.addEventListener("refreshInit", fitHeight);
// Scaling from the top edge keeps each card's top where the peek puts it
gsap.set(deck, { zIndex: (i: number) => deck.length - i, transformOrigin: "50% 0%", force3D: true });
const timeline = gsap.timeline({
scrollTrigger: {
trigger: section,
scroller: scrollerElement ?? undefined,
start: "top top",
end: () => `+=${deck.length * viewportHeight() * scrollLength}`,
pin: true,
scrub: smoothing > 0 ? smoothing : true,
invalidateOnRefresh: true,
},
});
// 1. The cards rise from below the fold into the stack, one a moment after another
deck.forEach((card, i) => {
timeline.fromTo(
card,
{ y: () => viewportHeight() * 0.72 + i * peek, scale: pose(i).scale * 0.9, rotation: 0 },
{ ...pose(i), ease: "power3.out", duration: 1.35 },
i * 0.06,
);
});
// A short rest with the whole stack in view
timeline.to({}, { duration: 0.35 });
const flyAt = timeline.duration();
// 2. The front card flies off the top and everything behind it moves up a place.
// The last card stays: it's where the section ends.
deck.slice(0, -1).forEach((card, i) => {
const behind = deck.slice(i + 1);
timeline.to(card, { y: () => -viewportHeight() * 1.15, rotation: -flyRotate, scale: 0.94, ease: "none", duration: 1 }, flyAt + i);
timeline.to(behind, { y: (index: number) => pose(index).y, scale: (index: number) => pose(index).scale, ease: "none", duration: 1 }, flyAt + i);
});
// A short rest on the last card before the section scrolls away
timeline.to({}, { duration: 0.4 });
const trigger = timeline.scrollTrigger;
if (!trigger) return;
// On a rebuild (a prop changed), taking the old pin out made the page shorter for a moment
// and the browser pulled the scroll back. Measure the new pin now and put the scroll back.
if (lastScroll.current > 0) {
trigger.refresh();
if (Math.abs(trigger.scroll() - lastScroll.current) > 1) trigger.scroll(lastScroll.current);
}
const scrollTarget: HTMLElement | Window = scrollerElement ?? window;
const onScroll = () => {
lastScroll.current = trigger.scroll();
};
scrollTarget.addEventListener("scroll", onScroll, { passive: true });
// Tabbing to a button on a card that's behind (or already gone) scrolls to where it's in front
const onFocusIn = (event: FocusEvent) => {
const index = deck.findIndex((card) => card.contains(event.target as Node));
if (index < 0) return;
const progress = Math.min(1, (flyAt + index) / timeline.duration());
trigger.scroll(trigger.start + (trigger.end - trigger.start) * progress);
};
section.addEventListener("focusin", onFocusIn);
return () => {
ScrollTrigger.removeEventListener("refreshInit", fitHeight);
scrollTarget.removeEventListener("scroll", onScroll);
section.removeEventListener("focusin", onFocusIn);
section.style.minHeight = "";
};
},
{ scope: sectionRef, dependencies: [cards.length, peek, scaleStep, flyRotate, scrollLength, smoothing, scroller, reducedMotion], revertOnUpdate: true },
);
return (
<section
ref={sectionRef}
className={`relative flex w-full flex-col items-center overflow-hidden px-5 pt-[clamp(1.75rem,4vh,2.75rem)] pb-[clamp(2rem,5vh,3.5rem)] ${reducedMotion ? "" : "min-h-[100dvh]"} ${className}`}
style={{ background }}
>
{children ? <div className={`pointer-events-none relative z-20 mt-8 mb-[clamp(1.5rem,5vh,3.5rem)] shrink-0 text-center ${headerClassName}`}>{children}</div> : null}
<div className="relative my-auto w-full max-w-[1100px]">
{/* Reduced motion: no pin, no flight. The cards are a plain column you scroll past. */}
<div className={reducedMotion ? "flex flex-col gap-5" : "relative h-[min(68vh,580px)] md:h-[min(62vh,540px)]"}>
{cards.map((card, index) => (
<article
key={`${index}-${card.title}`}
ref={(element) => {
cardRefs.current[index] = element;
}}
className={`${reducedMotion ? "relative h-[min(80vh,580px)] md:h-[min(62vh,540px)]" : "absolute inset-0 will-change-transform"} flex flex-col gap-4 rounded-[3px] p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)] md:grid md:grid-cols-[1.15fr_0.85fr] md:gap-[clamp(1rem,3vw,2.5rem)] md:p-[clamp(1.25rem,3vw,2.25rem)]`}
style={
{
background: card.background ?? tones[index % tones.length],
color: card.foreground ?? "#141414",
// The button's focus ring uses the card's text colour, so it shows on light and dark cards alike
"--card-fg": card.foreground ?? "#141414",
zIndex: reducedMotion ? undefined : cards.length - index,
} as CSSProperties & Record<"--card-fg", string>
}
>
<div className="order-2 flex min-h-0 min-w-0 flex-1 flex-col md:order-none">
<div className="mb-[0.65rem] flex items-start justify-between gap-4">
<h3 className="text-[clamp(2rem,4.5vw,3.4rem)] leading-none font-normal tracking-[-0.02em]" style={{ fontFamily: titleFont }}>
{card.title}
</h3>
<span aria-hidden="true" className="shrink-0 text-[clamp(1.5rem,3vw,2.25rem)] leading-none opacity-35" style={{ fontFamily: titleFont }}>
{String(index + 1).padStart(2, "0")}
</span>
</div>
{card.lede ? <p className="max-w-[28ch] text-[clamp(0.85rem,1.4vw,1rem)] leading-[1.45] opacity-75">{card.lede}</p> : null}
<div className="mt-auto pt-4">
<p className="max-w-[36ch] text-[clamp(0.8rem,1.2vw,0.92rem)] leading-[1.55] opacity-85">{card.body}</p>
{card.href ? (
<a
href={card.href}
className="mt-4 inline-flex items-center gap-3 bg-[#111] py-[0.55rem] pr-[0.55rem] pl-4 text-[0.85rem] text-white no-underline transition-transform duration-200 hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--card-fg)] md:mt-5"
>
{card.cta ?? "Discover our approach"}
<span aria-hidden="true" className="grid size-7 place-items-center text-[#111]" style={{ background: accent }}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2 7h10M8 3l4 4-4 4" />
</svg>
</span>
</a>
) : null}
</div>
</div>
<div className="order-1 flex h-[36%] min-h-[130px] shrink-0 justify-end md:order-none md:h-auto md:min-h-0">
<div className="h-full w-full overflow-hidden bg-[#1a1a1a] shadow-[0_10px_28px_rgba(0,0,0,0.14)] md:w-[min(100%,260px)]">
<img src={card.src} alt={card.alt ?? ""} className="block h-full w-full object-cover" draggable={false} decoding="async" />
</div>
</div>
</article>
))}
</div>
</div>
</section>
);
}
The component. Installs to components/dimaac/stack-fly-cards.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.jsonIt adds components/dimaac/stack-fly-cards.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/stack-fly-cards.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/stack-fly-cards.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { StackFlyCards } from "@/components/dimaac/stack-fly-cards" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
cards (required) | StackFlyCard[] | - | The cards, front card first. Three to six read best. |
peek | number | 42 | How far each card in the stack peeks out under the one in front, in pixels. |
scaleStep | number | 0.045 | How much smaller each card in the stack is than the one in front (0.05 is 5%). |
flyRotate | number | 25 | How far a card turns as it flies off, in degrees. |
scrollLength | number | 1 | How long the pinned section lasts: screens of scrolling per card. |
smoothing | number | 0.6 | How long the cards take to catch up with the scrollbar, in seconds. 0 follows it exactly. |
scroller | HTMLElement | string | null | null | The scrolling element (an element or a selector). Leave it out to use the page. |
background | string | "#121212" | The section's background. The cards' shadows look best on a dark one. |
accent | string | "#d6ef4a" | The colour of the square behind each button's arrow. |
titleFont | string | '"Instrument Serif", "Times New Roman", Times, serif' | The font of the card titles and numbers. Load it yourself (the demo loads Instrument Serif). |
children | ReactNode | - | Content above the deck, like a heading. It sits over the cards as they fly past. |
headerClassName | string | "" | Extra classes for the heading's box, e.g. "mix-blend-difference" so it inverts over the cards. |
className | string | "" | Extra classes for the section. It's one screen tall and pins while the cards move. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Stack Fly-Out Cards component (<StackFlyCards />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/stack-fly-cards
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/stack-fly-cards.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
Then use it like this:
import { StackFlyCards } from "@/components/dimaac/stack-fly-cards";
<StackFlyCards cards={cards} />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/stack-fly-cards.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- stack-fly-cards.md
- This page as Markdown: install, props and the full source
- stack-fly-cards.json
- The shadcn registry item
About
A pinned section for services, steps or case studies. As you scroll in, the cards rise from below the fold into a stack where each one peeks out under the one in front. Keep scrolling and the front card flies off the top with a turn while the rest move up a place, until only the last card is left. It's one scrubbed GSAP ScrollTrigger timeline, so scrolling back plays it in reverse. It works with the page or any scrolling element, rebuilds itself on resize, scrolls a card to the front when you tab to its button, and becomes a plain column of cards when the visitor prefers reduced motion.
- Installs to
components/dimaac/stack-fly-cards.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, scrolltrigger, pin, cards, stack, images
Portrait (lime blazer) by Amino on Lummi (Lummi License). Fashion photo (grey suit) by Axel Photography on Lummi (Lummi License). Interior by SHIHO on Lummi (Lummi License). Fashion portrait (orange visor) by West Kast on Lummi (Lummi License). Architecture (pastel rooftop) by Viri Gutiérrez on Lummi (Lummi License).