Stacking Cards on Scroll

Five full-width cards pin in sequence as you scroll; each arriving card slides over the previous one, which scales down to 0.9 and dims behind a translucent overlay, so the section ends as a neat stepped stack.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1310 words of exact values — rewritten live as you tune the Playground.

prompt.md · Stacking Cards on Scroll
### EFFECT: Stacking Cards on Scroll
**What it does (visual):** After a full-height intro ("How a project moves through us"), the user scrolls into five full-width cards, each about 80% of the viewport tall, with large rounded corners, a hairline border and its own soft gradient tint (deep blue, rust, moss, violet, olive) over the surface colour. Each card shows a huge light serif number ("01" … "05") top-left and a title plus two lines of copy bottom-right. The first card scrolls up and locks 16px from the top of the viewport. The second card slides up over it and locks 14px lower; while it travels from the bottom of the viewport to its lock line, the first card shrinks about its top edge from scale 1 to 0.9 and a translucent overlay of the page colour fades to 0.55 opacity over it — so it dims and recedes. The third card does the same to the second (1 → 0.9) while pushing the first down another level (a further 0.04 smaller, overlay +0.15 — at the defaults 0.9 → 0.86 and 55% → 70%), and so on. Each arriving card's number and text settle in with a 40px rise and fade (content settle enabled: true). When the fifth card locks, you see a neat stepped stack: four 14px edges of progressively narrower, dimmer cards peeking above the active one. The stack holds for 40vh of scroll, then the whole pile scrolls away together and a normal outro section follows. Everything is scrubbed and reversible.

**Tech stack:**
- GSAP 3.13+ with ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`

**HTML structure:**
```html
<section class="stk-intro"><p class="stk-intro__label">Scroll · Stacking cards</p><h1 class="stk-intro__title">How a project<br>moves through us</h1></section>
<section class="stk">
  <article class="stk__card" style="--tint:#2a3d5c">
    <div class="stk__inner">
      <span class="stk__num">01</span>
      <div class="stk__body"><h2 class="stk__title">Listen</h2><p class="stk__copy">Two weeks of conversations before a single screen is drawn.</p></div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
  <!-- repeat for 02 Shape (--tint:#5c3a2a), 03 Build (#2a5c48), 04 Tune (#4a2a5c), 05 Hand over (#5c5a2a) -->
</section>
<section class="stk-outro"><p class="stk-outro__text">The finished stack scrolls away together and the page continues.</p></section>
```
`.stk__card` is the element that gets pinned; `.stk__inner` is what scales; `.stk__shade` is the dimming overlay.

**CSS requirements:**
- `.stk-intro`, `.stk-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;`
- `.stk`: `position: relative; padding: 0 4vw 40vh;` (the bottom padding is how long the finished stack holds).
- `.stk__card`: `height: 80vh; padding-top: 16px;` — the padding is room for the stepped pin offsets.
- `.stk__inner`: `position: relative; height: calc(100% - 16px); overflow: hidden; border-radius: clamp(14px, 2vw, 28px); display: flex; flex-direction: column; justify-content: space-between; gap: 2rem; padding: clamp(1.5rem, 4vw, 3.5rem); border: 1px solid var(--line, rgba(255,255,255,.08)); background: radial-gradient(120% 100% at 18% 0%, color-mix(in srgb, var(--tint) 62%, var(--surface)), transparent 60%), radial-gradient(80% 60% at 90% 100%, color-mix(in srgb, var(--tint) 30%, var(--surface)), transparent 70%), var(--surface, #121212); box-shadow: 0 -12px 40px -24px rgba(0,0,0,.6); transform-origin: top center; will-change: transform;` — **`transform-origin: top center` is essential**: scaling about the top edge is what leaves the stepped edge visible.
- `.stk__num`: display serif, `font-weight: 300; font-size: clamp(4rem, 14vw, 13rem); line-height: .8; letter-spacing: -0.05em; color: color-mix(in srgb, var(--text) 85%, var(--tint));`
- `.stk__body`: `max-width: 46ch; align-self: flex-end;` `.stk__title`: display serif `font-size: clamp(1.75rem, 3.6vw, 3.5rem); letter-spacing: -0.03em; line-height: 1;` `.stk__copy`: `margin-top: 1rem; color: var(--muted); line-height: 1.5;`
- `.stk__shade`: `position: absolute; inset: 0; background: var(--bg, #0a0a0a); opacity: 0; pointer-events: none;`
- Under 768px: `.stk__card { height: 72vh; }` and `.stk__body { align-self: flex-start; }`.

**Animation spec (exact values):**
1. Constants: `BASE_OFFSET = 16` (px), `STEP = 14` (px; ≈0.7 × that under 768px), `FIRST_SCALE = 0.9`, `DEEPER_STEP = 0.04`, `SHADE = 0.55` (+0.15 per deeper level, capped at 0.85), `SETTLE = true`, `HOLD = 40vh`. Helpers: `pinLine(i) = BASE_OFFSET + i × STEP`; `scaleFor(level) = 0.9 − 0.04 × (level − 1)`; `shadeFor(level) = min(0.85, 0.55 + 0.15 × (level − 1))` where level 1 = directly beneath the arriving card.
2. Pin — for each card `i` (`gsap.utils.toArray('.stk__card')`):
   ```js
   ScrollTrigger.create({ trigger: card, start: () => `top ${pinLine(i)}px`, endTrigger: '.stk', end: 'bottom bottom',
     pin: true, pinSpacing: false, anticipatePin: 1, invalidateOnRefresh: true, markers: false });
   ```
   Card 0 pins at 16px and card `i` at `16 + i × 14px` (at the default 14px step: 30, 44, 58, 72px); all stay pinned until the section's bottom meets the viewport bottom, so they release and scroll away together.
3. Shrink + dim — for each card `i` and every earlier card `j < i`, with `level = i − j`:
   ```js
   const trig = { trigger: card /* i */, start: 'top bottom', end: () => `top ${pinLine(i)}px`, scrub: true, invalidateOnRefresh: true };
   gsap.fromTo(cards[j].querySelector('.stk__inner'), { scale: level === 1 ? 1 : scaleFor(level - 1) }, { scale: scaleFor(level), ease: 'none', immediateRender: false, scrollTrigger: trig });
   gsap.fromTo(cards[j].querySelector('.stk__shade'), { opacity: level === 1 ? 0 : shadeFor(level - 1) }, { opacity: shadeFor(level), ease: 'none', immediateRender: false, scrollTrigger: { ...trig } });
   ```
   So while card `i` travels from the viewport bottom to its pin line: the card directly beneath goes `scale 1 → 0.9`, `shade 0 → 0.55`; each deeper level steps a further 0.04 smaller and +0.15 darker (at the defaults: `0.9 → 0.86`, `0.55 → 0.70`; then `0.86 → 0.82`, `0.70 → 0.85`; then `0.82 → 0.78`, `0.85 → 0.85`). Explicit `fromTo` values (not chained `to`) keep every level correct after a `refresh()`.
4. Content settle (enabled: true) — per card: `gsap.from(card.querySelectorAll('.stk__num, .stk__body'), { y: 40, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: card, start: 'top 85%', toggleActions: 'play none none reverse' } })`.
5. Intro: `gsap.from('.stk-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`. After `document.fonts.ready` → `ScrollTrigger.refresh()`.

**Interaction:** None beyond scrolling; the stack builds and unbuilds with the scrollbar.

**Responsive behavior:** `gsap.matchMedia()` with `(min-width: 768px)` / `(max-width: 767px)`: under 768px the step shrinks to ≈0.7 × 14px (10px at the default) and cards are 72vh tall with the body text top-aligned under the number (single column). Pinning works on phones; `anticipatePin: 1` hides the one-frame jump on fast flicks.

**Accessibility:** With `prefers-reduced-motion: reduce` nothing is pinned or scrubbed: cards sit in normal flow (`height: auto; min-height: 50vh; margin-bottom: 4vw`) at full scale with no overlay — a plain list of steps. Cards are `<article>`s with real `<h2>` titles; the overlay is `aria-hidden`.

**Performance rules:** Only `transform` (scale) and `opacity` (overlay) are animated — never `filter: brightness()` on a card-sized element. `will-change: transform` on `.stk__inner` only. `pinSpacing: false` keeps the document height unchanged so no layout happens during pinning. All triggers live inside `gsap.context()` and are reverted on unmount (`ctx.revert()` unpins and removes spacers).

**Customization knobs:**
- `STEP` (14px) — the visible edge of each stacked card; `BASE_OFFSET` (16px) — top margin of the stack.
- `FIRST_SCALE` (0.9) and `DEEPER_STEP` (0.04) — how far cards recede; set `DEEPER_STEP: 0` for an equal-width pile.
- `SHADE` (0.55) — how dark covered cards get.
- `SETTLE` (true) — the 40px rise + fade of each arriving card's number and body.
- `.stk` `padding-bottom` (40vh) — how long the finished stack holds before scrolling away.
- `--tint` per card — the gradient hue.

**Acceptance criteria:**
- [ ] Card 1 locks 16px from the top; each later card locks exactly 14px lower than the previous one.
- [ ] While a card arrives, the card beneath shrinks 1 → 0.9 about its top edge and dims to 0.55; deeper cards step down a further 0.04 / +0.15.
- [ ] With all five cards in, four stepped edges are visible above the active card, each narrower and dimmer than the one below it.
- [ ] The stack holds for 40vh, then all cards scroll away together; scrolling up reverses everything.
- [ ] Reduced-motion users see five normal, unpinned cards.
- [ ] No console errors, no layout shift when pins start.

Source

Full source code

<section class="stk-intro">
  <p class="stk-intro__label">Scroll · Stacking cards</p>
  <h1 class="stk-intro__title">How a project<br>moves through us</h1>
</section>

<section class="stk">
  <article class="stk__card" style="--tint:#2a3d5c">
    <div class="stk__inner">
      <span class="stk__num">01</span>
      <div class="stk__body">
        <h2 class="stk__title">Listen</h2>
        <p class="stk__copy">Two weeks of conversations before a single screen is drawn. We write down what the product must never do.</p>
      </div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
  <article class="stk__card" style="--tint:#5c3a2a">
    <div class="stk__inner">
      <span class="stk__num">02</span>
      <div class="stk__body">
        <h2 class="stk__title">Shape</h2>
        <p class="stk__copy">Rough, honest prototypes in the browser — type, rhythm and motion first, colour last.</p>
      </div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
  <article class="stk__card" style="--tint:#2a5c48">
    <div class="stk__inner">
      <span class="stk__num">03</span>
      <div class="stk__body">
        <h2 class="stk__title">Build</h2>
        <p class="stk__copy">Production code from day one. Every component ships with its own motion spec and reduced-motion state.</p>
      </div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
  <article class="stk__card" style="--tint:#4a2a5c">
    <div class="stk__inner">
      <span class="stk__num">04</span>
      <div class="stk__body">
        <h2 class="stk__title">Tune</h2>
        <p class="stk__copy">Real devices, slow networks, bright rooms. We cut whatever does not survive the second visit.</p>
      </div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
  <article class="stk__card" style="--tint:#5c5a2a">
    <div class="stk__inner">
      <span class="stk__num">05</span>
      <div class="stk__body">
        <h2 class="stk__title">Hand over</h2>
        <p class="stk__copy">Documented tokens, a motion library and a team that knows why each decision was made.</p>
      </div>
      <div class="stk__shade" aria-hidden="true"></div>
    </div>
  </article>
</section>

<section class="stk-outro">
  <p class="stk-outro__text">The finished stack scrolls away together and the page continues.</p>
</section>