interactionpatterns

Sticky stacking cards

Description

demo

Scroll inside this frame.

01

Pin

Every card is position: sticky. The page scrolls normally and the browser does the pinning; nothing hijacks the scroll.

02

Offset

Each card pins one title strip lower than the card before, so earlier cards stay visible as a pile of titles.

03

Release

Bottom margins step down one strip per card, so every card lets go at the same scroll position and the stack leaves as one.

04

Track

A side index lights the card being read. It shows only when there is room beside the stack.

The stack leaves together.

Cards pin as you scroll, each one sliding over the last until only their title strips show, then the whole stack leaves together.

prompt
Turn this list of sections into sticky stacking cards: as the reader scrolls, each card pins near the top of the window, the next card slides over it, and earlier cards stay visible as a pile of title strips. When the list ends, the whole stack scrolls away together.

This is plain `position: sticky`, not scroll-jacking. The page scrolls normally and there are no scroll-driven animations; do not add any scroll-linked JavaScript to move the cards.

Structure:
- A container `.cards` holding N `<article class="card">` elements, in normal block flow (not flex or grid, because the layout relies on margin collapsing) and with `display: flow-root`, so the last card's bottom margin stays inside it.
- Each card starts with a title strip (`<header>` with a two-digit number and the card title) of fixed height `--strip`. Everything else in the card goes below the strip.
- Give each card its index as a custom property, `style="--i: 0"`, `--i: 1`, …, and set `--n` (the card count) on the container.
- All cards must have the same height. If they differ, they release at different moments and the stack breaks apart while leaving.
- Cards need an opaque background, so a card hides the one it slides over, plus a soft upward shadow (for example `box-shadow: 0 -18px 28px -12px rgb(0 0 0 / 0.35)`) so the overlap reads.

Sticky math:
- `--strip`: 52px on small screens, 64px from 760px wide. `--pin`: the sticky header height plus 8px (16px from 760px). `--gap`: 24px between cards in normal flow.
- `top: calc(var(--pin) + var(--i) * var(--strip))`, so each card pins one strip lower than the card before.
- `margin-bottom: calc((var(--n) - var(--i)) * var(--strip))`. The extra margin is one strip longer for each earlier card, cancelling out its higher pin, so every card's margin box ends at the same point and all of them hit the end of `.cards` at the same scroll position.
- `.card + .card { margin-top: calc(var(--gap) - (var(--n) + 1 - var(--i)) * var(--strip)); }`. This negative margin collapses with the previous card's bottom margin, so the visible gap stays `--gap`.
- Wrap the sticky rules in `@media (min-height: 700px)`. On short windows a whole card cannot fit under the stack, so let the cards flow normally there.

Side index (optional, from 1000px wide):
- Make the section a two-column grid: a narrow index column (about 176px) and the cards. The index is an `<ol aria-hidden="true">` of short card labels, `position: sticky` at about `--pin + 22px`, hidden below 1000px.
- Highlight the entry for the card being read: on `scroll` (passive listener, throttled with `requestAnimationFrame`), the active card is the last one whose `getBoundingClientRect().top` is above 55% of `innerHeight`. Toggle an `on` class; transition only `color`, over 200ms, and turn the transition off under `prefers-reduced-motion: reduce`.

Pitfalls:
- `position: sticky` does nothing if any ancestor between the card and the scroller has `overflow: hidden`, `auto` or `scroll`. Use `overflow: clip` if something must be clipped.
- An image inside a card must not set the card's height; position it absolutely inside a sized box, or the cards stop matching.
- Check the result with viewport screenshots at several scroll positions; full-page screenshots do not show pinning.
- Verify at 390px and 1440px wide: mid-scroll the strips stack 52px or 64px apart, and at the end all cards move up together with no strip clipped.

Code sample

Framework
StackingCards.tsx
import { useEffect, useRef, useState, type CSSProperties } from "react";
import "./stacking-cards.css";

export interface StackCard {
  /** Unique; also the label in the side index. */
  key: string;
  title: string;
  text: string;
}

export function StackingCards({ cards }: { cards: StackCard[] }) {
  const list = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(0);

  // The pinning is all CSS. This only lights the index entry for the card
  // being read: the last one whose top has passed 55% of the window.
  useEffect(() => {
    const items = [...(list.current?.children ?? [])] as HTMLElement[];
    let frame = 0;

    const update = () => {
      frame = 0;
      const line = window.innerHeight * 0.55;
      let next = 0;
      items.forEach((card, i) => {
        if (card.getBoundingClientRect().top < line) next = i;
      });
      setActive(next);
    };

    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    update();
    return () => {
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(frame);
    };
  }, [cards.length]);

  return (
    <section className="stack" style={{ "--n": cards.length } as CSSProperties}>
      <ol className="stack-index" aria-hidden="true">
        {cards.map((card, i) => (
          <li key={card.key} className={i === active ? "on" : undefined}>
            {card.key}
          </li>
        ))}
      </ol>

      <div className="stack-cards" ref={list}>
        {cards.map((card, i) => (
          <article key={card.key} className="stack-card" style={{ "--i": i } as CSSProperties}>
            <header className="stack-strip">
              <span className="stack-num">{String(i + 1).padStart(2, "0")}</span>
              <h3>{card.title}</h3>
            </header>
            <p>{card.text}</p>
          </article>
        ))}
      </div>
    </section>
  );
}
stacking-cards.css
/*
 * Sticky stacking cards. Each card is position: sticky and pins one title
 * strip lower than the one before, so earlier cards stay as a pile of titles.
 * Bottom margins step down one strip per card, so every card lets go at the
 * same scroll position and the stack leaves as one.
 *
 * Set --header-h to your sticky header's height, and --card-h so every card
 * is the same height: cards of different heights release at different times.
 */
.stack {
  --strip: 52px;
  --pin: calc(var(--header-h, 0px) + 8px);
  --gap: 24px;
}

@media (min-width: 760px) {
  .stack {
    --strip: 64px;
    --pin: calc(var(--header-h, 0px) + 16px);
  }
}

.stack-index {
  display: none;
}

/* Normal block flow, not flex or grid: the layout relies on margin
   collapsing. flow-root keeps the last card's margin inside the list. */
.stack-cards {
  display: flow-root;
}

.stack-card {
  box-sizing: border-box;
  height: var(--card-h, 420px);
  padding: 0 20px 20px;
  /* Opaque, so a card hides the one it slides over */
  background: #fff;
}

.stack-card + .stack-card {
  margin-top: var(--gap);
}

.stack-strip {
  display: flex;
  align-items: center;
  gap: 16px;
  height: var(--strip);
}

.stack-strip h3 {
  margin: 0;
  font-size: 1rem;
}

.stack-num {
  color: #888;
  font-variant-numeric: tabular-nums;
}

/* Pin only when a whole card fits under the stack. */
@media (min-height: 700px) {
  .stack-card {
    position: sticky;
    top: calc(var(--pin) + var(--i) * var(--strip));
    margin-bottom: calc((var(--n) - var(--i)) * var(--strip));
    box-shadow: 0 -10px 16px -12px rgb(0 0 0 / 0.3);
  }

  /* Collapses with the previous card's bottom margin; the gap stays --gap. */
  .stack-card + .stack-card {
    margin-top: calc(var(--gap) - (var(--n) + 1 - var(--i)) * var(--strip));
  }
}

/* A side index that lights the card being read, when there is room. */
@media (min-width: 1000px) {
  .stack {
    display: grid;
    grid-template-columns: 176px minmax(0, 1fr);
    align-items: start;
    gap: 32px;
  }

  .stack-index {
    position: sticky;
    top: calc(var(--pin) + 22px);
    display: grid;
    gap: 14px;
    margin: 0;
    padding: 0;
    list-style: none;
    color: #aaa;
  }

  .stack-index li {
    transition: color 200ms ease;
  }

  .stack-index li.on {
    color: #111;
  }
}

@media (prefers-reduced-motion: reduce) {
  .stack-index li {
    transition: none;
  }
}
StackingCards.svelte
<script lang="ts">
  import "./stacking-cards.css";

  interface StackCard {
    /** Unique; also the label in the side index. */
    key: string;
    title: string;
    text: string;
  }

  let { cards }: { cards: StackCard[] } = $props();
  let list: HTMLDivElement;
  let active = $state(0);

  // The pinning is all CSS. This only lights the index entry for the card
  // being read: the last one whose top has passed 55% of the window.
  $effect(() => {
    cards.length; // set up again when the cards change
    const items = [...list.children] as HTMLElement[];
    let frame = 0;

    const update = () => {
      frame = 0;
      const line = window.innerHeight * 0.55;
      let next = 0;
      items.forEach((card, i) => {
        if (card.getBoundingClientRect().top < line) next = i;
      });
      active = next;
    };

    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    update();
    return () => {
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(frame);
    };
  });
</script>

<section class="stack" style:--n={cards.length}>
  <ol class="stack-index" aria-hidden="true">
    {#each cards as card, i (card.key)}
      <li class:on={i === active}>{card.key}</li>
    {/each}
  </ol>

  <div class="stack-cards" bind:this={list}>
    {#each cards as card, i (card.key)}
      <article class="stack-card" style:--i={i}>
        <header class="stack-strip">
          <span class="stack-num">{String(i + 1).padStart(2, "0")}</span>
          <h3>{card.title}</h3>
        </header>
        <p>{card.text}</p>
      </article>
    {/each}
  </div>
</section>
stacking-cards.css
/*
 * Sticky stacking cards. Each card is position: sticky and pins one title
 * strip lower than the one before, so earlier cards stay as a pile of titles.
 * Bottom margins step down one strip per card, so every card lets go at the
 * same scroll position and the stack leaves as one.
 *
 * Set --header-h to your sticky header's height, and --card-h so every card
 * is the same height: cards of different heights release at different times.
 */
.stack {
  --strip: 52px;
  --pin: calc(var(--header-h, 0px) + 8px);
  --gap: 24px;
}

@media (min-width: 760px) {
  .stack {
    --strip: 64px;
    --pin: calc(var(--header-h, 0px) + 16px);
  }
}

.stack-index {
  display: none;
}

/* Normal block flow, not flex or grid: the layout relies on margin
   collapsing. flow-root keeps the last card's margin inside the list. */
.stack-cards {
  display: flow-root;
}

.stack-card {
  box-sizing: border-box;
  height: var(--card-h, 420px);
  padding: 0 20px 20px;
  /* Opaque, so a card hides the one it slides over */
  background: #fff;
}

.stack-card + .stack-card {
  margin-top: var(--gap);
}

.stack-strip {
  display: flex;
  align-items: center;
  gap: 16px;
  height: var(--strip);
}

.stack-strip h3 {
  margin: 0;
  font-size: 1rem;
}

.stack-num {
  color: #888;
  font-variant-numeric: tabular-nums;
}

/* Pin only when a whole card fits under the stack. */
@media (min-height: 700px) {
  .stack-card {
    position: sticky;
    top: calc(var(--pin) + var(--i) * var(--strip));
    margin-bottom: calc((var(--n) - var(--i)) * var(--strip));
    box-shadow: 0 -10px 16px -12px rgb(0 0 0 / 0.3);
  }

  /* Collapses with the previous card's bottom margin; the gap stays --gap. */
  .stack-card + .stack-card {
    margin-top: calc(var(--gap) - (var(--n) + 1 - var(--i)) * var(--strip));
  }
}

/* A side index that lights the card being read, when there is room. */
@media (min-width: 1000px) {
  .stack {
    display: grid;
    grid-template-columns: 176px minmax(0, 1fr);
    align-items: start;
    gap: 32px;
  }

  .stack-index {
    position: sticky;
    top: calc(var(--pin) + 22px);
    display: grid;
    gap: 14px;
    margin: 0;
    padding: 0;
    list-style: none;
    color: #aaa;
  }

  .stack-index li {
    transition: color 200ms ease;
  }

  .stack-index li.on {
    color: #111;
  }
}

@media (prefers-reduced-motion: reduce) {
  .stack-index li {
    transition: none;
  }
}