---
title: Stack Fly-Out Cards
description: Cards rise into a peeking stack, then fly off one by one as you scroll.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/stack-fly-cards
registry: https://ui.dimaac.com/r/stack-fly-cards.json
---

# Stack Fly-Out Cards

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/stack-fly-cards`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/stack-fly-cards.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { StackFlyCards } from "@/components/dimaac/stack-fly-cards" and give its parent a size: it fills it.

## Usage

```tsx
import { StackFlyCards } from "@/components/dimaac/stack-fly-cards";

<StackFlyCards cards={cards} />
```

## 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. |

## Credits

- Portrait (lime blazer): Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Fashion photo (grey suit): Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License
- Interior: SHIHO, Lummi (https://www.lummi.ai/photo/minimalist-rustic-interior-qppyl), Lummi License
- Fashion portrait (orange visor): West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Architecture (pastel rooftop): Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### stack-fly-cards.tsx → `components/dimaac/stack-fly-cards.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { StackFlyCards, type StackFlyCard, type StackFlyCardsProps } from "@/components/dimaac/stack-fly-cards";

// A quiet intro, the pinned deck of five cards, and an outro to land on.
// Photos from Lummi (Lummi License), each picked to sit well on its card's colour.

const serif = '"Instrument Serif", "Times New Roman", Times, serif';

const photos: StackFlyCard[] = [
  {
    src: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg",
    alt: "A person with curly red hair in a lime green blazer against a bright pink background",
    title: "Lime blazer",
    body: "Curly red hair, a lime green blazer and a flat pink wall. The colours are loud and the pose is quiet.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/fashion/grey-suit-stride-4x5.jpg",
    alt: "A person in a grey suit and platform shoes mid-stride against a clean blue background",
    title: "Grey suit",
    body: "A grey suit, tall platform shoes and one long stride across a clean blue studio floor.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/rustic-interior-4x5.jpg",
    alt: "A calm rustic interior with textured beige walls and soft daylight",
    title: "Rustic interior",
    body: "Textured beige walls and soft daylight from one side. Nothing in the room asks for attention.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg",
    alt: "A woman in an orange visor and navy dress with yellow earrings against a teal background",
    title: "Orange visor",
    body: "An orange visor, a navy dress and yellow earrings against deep teal. Three colours and nothing else.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg",
    alt: "A purple wall and a pink building under a blue sky, with one small figure on the roof",
    title: "Pastel rooftop",
    body: "A purple wall, a pink building and one small figure on the roof under a pale blue sky.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
];

function Glow({ className }: { className: string }) {
  return <div aria-hidden="true" className={`pointer-events-none absolute rounded-full blur-[90px] ${className}`} />;
}

export default function Demo(props: Partial<StackFlyCardsProps>) {
  return (
    <div className="w-full bg-[#eceae4] text-[#141414] [--stack-bg:#121212] dark:bg-[#0c0c0b] dark:text-[#ecebe6] dark:[--stack-bg:#0c0c0b]" style={{ fontFamily: '"DM Sans", system-ui, sans-serif' }}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500&family=Instrument+Serif:ital@0;1&display=swap" precedence="default" />

      <section className="relative grid min-h-[100dvh] place-items-center overflow-hidden px-6 text-center">
        <Glow className="top-[12%] left-[8%] h-[46vh] w-[52vw] bg-[#d6ef4a]/30 dark:bg-[#d6ef4a]/10" />
        <Glow className="right-[6%] bottom-[10%] h-[40vh] w-[42vw] bg-[#6db5a8]/30 dark:bg-[#6db5a8]/12" />
        <div className="relative max-w-[28rem]">
          <h1 className="mb-4 text-[clamp(2.75rem,7vw,4.5rem)] leading-[1.05] tracking-[-0.02em]" style={{ fontFamily: serif }}>
            Five cards, one stack
          </h1>
          <p className="text-[1.05rem] leading-[1.55] opacity-65">Scroll in and they fly out one by one.</p>
        </div>
        <p className="absolute bottom-8 left-1/2 -translate-x-1/2 text-[0.7rem] tracking-[0.18em] uppercase opacity-50">Scroll</p>
      </section>

      <StackFlyCards cards={photos} background="var(--stack-bg)" headerClassName="mix-blend-difference text-white" {...props} className={props.className ?? ""}>
        <h2 className="text-[clamp(1.75rem,3.5vw,2.5rem)] leading-none" style={{ fontFamily: serif }}>
          DiMaac
        </h2>
      </StackFlyCards>

      <section id="stack-fly-cards-outro" className="relative grid min-h-[100dvh] place-items-center overflow-hidden px-6 text-center">
        <Glow className="top-[10%] right-[10%] h-[44vh] w-[46vw] bg-[#c8b8e8]/40 dark:bg-[#c8b8e8]/12" />
        <Glow className="bottom-[8%] left-[6%] h-[40vh] w-[40vw] bg-[#e8d5c4]/50 dark:bg-[#e8d5c4]/10" />
        <div className="relative max-w-[28rem]">
          <h2 className="mb-4 text-[clamp(2.75rem,7vw,4.5rem)] leading-[1.05] tracking-[-0.02em]" style={{ fontFamily: serif }}>
            The end
          </h2>
          <p className="text-[1.05rem] leading-[1.55] opacity-65">That's the stack. Scroll back up to fly it again.</p>
        </div>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
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.txt
```
