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.
- Scroll
- gsap
- scrolltrigger
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Stacking Cards on Scroll**. Use my existing list of cards: give each the class `stk__card` (`height: 80vh; padding-top: 16px`) with an inner `.stk__inner` wrapper (`position: relative; overflow: hidden; border-radius; transform-origin: top center`) and an absolutely positioned `.stk__shade` overlay (`background: var(--bg); opacity: 0`). With GSAP ScrollTrigger, pin each card `i` with `start: 'top ' + (16 + i × 14) + 'px'`, `endTrigger: section`, `end: 'bottom bottom'`, `pinSpacing: false`, `anticipatePin: 1`. For every earlier card `j` while card `i` arrives (`trigger: card i, start: 'top bottom', end: 'top ' + (16 + i × 14) + 'px', scrub: true`) tween its inner `scale` with `fromTo` from `1` (or its previous level) to `0.9 − 0.04 × (i − j − 1)` and its shade `opacity` to `min(0.85, 0.55 + 0.15 × (i − j − 1))`, `ease: 'none'`, `immediateRender: false`. Give the section `padding-bottom: 40vh` so the stack holds. If true, also rise + fade each arriving card's content (`y 40→0, opacity 0→1, 0.9s, power3.out`). Skip all pinning for `prefers-reduced-motion`. Wrap in `gsap.context()` and revert on unmount. Keep my colours, fonts and card copy. ### EFFECT (React / Next.js): Stacking Cards on Scroll
Build **Stacking Cards on Scroll** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/StackingCardsOnScroll.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function StackingCardsOnScroll() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="stk-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`StackingCardsOnScroll.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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. 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>
```
```css
.stk-intro, .stk-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.stk-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.stk-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .9; }
.stk-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.stk { position: relative; padding: 0 4vw var(--knob-hold, 40vh); }
.stk__card { height: 80vh; padding-top: 16px; } /* room above the card 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);
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);
border: 1px solid var(--line);
box-shadow: 0 -12px 40px -24px rgba(0,0,0,.6);
transform-origin: top center; will-change: transform;
}
.stk__inner::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.025) 0 1px, transparent 1px 56px); }
.stk__num { font-family: var(--font-display); 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; text-align: left; }
.stk__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3.6vw, 3.5rem); letter-spacing: -0.03em; line-height: 1; }
.stk__copy { margin-top: 1rem; color: var(--muted); font-size: clamp(.95rem, 1.15vw, 1.1rem); line-height: 1.5; }
.stk__shade { position: absolute; inset: 0; background: var(--bg); opacity: 0; pointer-events: none; }
@media (max-width: 767px) {
.stk__card { height: 72vh; }
.stk__body { align-self: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.stk { padding-bottom: 10vh; }
.stk__card { height: auto; padding-top: 0; margin-bottom: 4vw; }
.stk__inner { height: auto; min-height: 50vh; will-change: auto; }
}
```
```js
// Stacking Cards on Scroll — cards pin in sequence; each new arrival shrinks and dims the ones beneath it.
gsap.registerPlugin(ScrollTrigger);
const BASE_OFFSET = 16; // px from the viewport top for the first card's pin line
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 14, firstScale: 0.9, deeperStep: 0.04, shade: 0.55, hold: 40, settle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.stk');
const cards = gsap.utils.toArray('.stk__card');
let ctx, built = false;
function build() {
ctx?.revert();
section.style.setProperty('--knob-hold', K('hold') + 'vh'); // how long the finished stack holds (section padding-bottom)
const STEP = K('step'); // px added per card → the stepped edge of the stack
const FIRST_SCALE = K('firstScale'); // the card directly beneath the active one
const DEEPER_STEP = K('deeperStep'); // each level further down shrinks by this much more
const SHADE = K('shade'); // overlay opacity of the card directly beneath (deeper cards add 0.15 each, capped at 0.85)
const settle = K('settle');
const scaleFor = (level) => FIRST_SCALE - DEEPER_STEP * (level - 1); // level 1 = directly beneath
const shadeFor = (level) => Math.min(0.85, SHADE + 0.15 * (level - 1));
ctx = gsap.context(() => {
if (!built) gsap.from('.stk-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // cards simply stack in normal flow
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const step = mmCtx.conditions.mobile ? Math.round(STEP * 0.7) : STEP;
const pinLine = (i) => BASE_OFFSET + i * step;
cards.forEach((card, i) => {
// 1 · pin each card at its own stepped line until the whole section has scrolled through
ScrollTrigger.create({
trigger: card, start: () => `top ${pinLine(i)}px`, endTrigger: section, end: 'bottom bottom',
pin: true, pinSpacing: false, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
});
// 2 · as card i arrives (viewport bottom → its pin line) every card beneath shrinks one level and dims
for (let j = 0; j < i; j++) {
const level = i - j; // 1 = directly beneath the arriving card
const inner = cards[j].querySelector('.stk__inner');
const shade = cards[j].querySelector('.stk__shade');
const trig = { trigger: card, start: 'top bottom', end: () => `top ${pinLine(i)}px`, scrub: true, invalidateOnRefresh: true, markers: false };
gsap.fromTo(inner, { scale: level === 1 ? 1 : scaleFor(level - 1) }, { scale: scaleFor(level), ease: 'none', immediateRender: false, scrollTrigger: trig });
gsap.fromTo(shade, { opacity: level === 1 ? 0 : shadeFor(level - 1) }, { opacity: shadeFor(level), ease: 'none', immediateRender: false, scrollTrigger: { ...trig } });
}
// 3 · the arriving card's content settles in slightly after its frame
if (settle) 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' },
});
});
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
built = true;
}
build();
// Playground: rebuild (ctx.revert() unpins every card) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Stacking Cards on Scroll
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Stacking Cards on Scroll** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stacking Cards on Scroll</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<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>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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. 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>
```
```css
.stk-intro, .stk-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.stk-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.stk-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .9; }
.stk-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.stk { position: relative; padding: 0 4vw var(--knob-hold, 40vh); }
.stk__card { height: 80vh; padding-top: 16px; } /* room above the card 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);
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);
border: 1px solid var(--line);
box-shadow: 0 -12px 40px -24px rgba(0,0,0,.6);
transform-origin: top center; will-change: transform;
}
.stk__inner::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.025) 0 1px, transparent 1px 56px); }
.stk__num { font-family: var(--font-display); 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; text-align: left; }
.stk__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3.6vw, 3.5rem); letter-spacing: -0.03em; line-height: 1; }
.stk__copy { margin-top: 1rem; color: var(--muted); font-size: clamp(.95rem, 1.15vw, 1.1rem); line-height: 1.5; }
.stk__shade { position: absolute; inset: 0; background: var(--bg); opacity: 0; pointer-events: none; }
@media (max-width: 767px) {
.stk__card { height: 72vh; }
.stk__body { align-self: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.stk { padding-bottom: 10vh; }
.stk__card { height: auto; padding-top: 0; margin-bottom: 4vw; }
.stk__inner { height: auto; min-height: 50vh; will-change: auto; }
}
```
```js
// Stacking Cards on Scroll — cards pin in sequence; each new arrival shrinks and dims the ones beneath it.
gsap.registerPlugin(ScrollTrigger);
const BASE_OFFSET = 16; // px from the viewport top for the first card's pin line
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 14, firstScale: 0.9, deeperStep: 0.04, shade: 0.55, hold: 40, settle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.stk');
const cards = gsap.utils.toArray('.stk__card');
let ctx, built = false;
function build() {
ctx?.revert();
section.style.setProperty('--knob-hold', K('hold') + 'vh'); // how long the finished stack holds (section padding-bottom)
const STEP = K('step'); // px added per card → the stepped edge of the stack
const FIRST_SCALE = K('firstScale'); // the card directly beneath the active one
const DEEPER_STEP = K('deeperStep'); // each level further down shrinks by this much more
const SHADE = K('shade'); // overlay opacity of the card directly beneath (deeper cards add 0.15 each, capped at 0.85)
const settle = K('settle');
const scaleFor = (level) => FIRST_SCALE - DEEPER_STEP * (level - 1); // level 1 = directly beneath
const shadeFor = (level) => Math.min(0.85, SHADE + 0.15 * (level - 1));
ctx = gsap.context(() => {
if (!built) gsap.from('.stk-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // cards simply stack in normal flow
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const step = mmCtx.conditions.mobile ? Math.round(STEP * 0.7) : STEP;
const pinLine = (i) => BASE_OFFSET + i * step;
cards.forEach((card, i) => {
// 1 · pin each card at its own stepped line until the whole section has scrolled through
ScrollTrigger.create({
trigger: card, start: () => `top ${pinLine(i)}px`, endTrigger: section, end: 'bottom bottom',
pin: true, pinSpacing: false, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
});
// 2 · as card i arrives (viewport bottom → its pin line) every card beneath shrinks one level and dims
for (let j = 0; j < i; j++) {
const level = i - j; // 1 = directly beneath the arriving card
const inner = cards[j].querySelector('.stk__inner');
const shade = cards[j].querySelector('.stk__shade');
const trig = { trigger: card, start: 'top bottom', end: () => `top ${pinLine(i)}px`, scrub: true, invalidateOnRefresh: true, markers: false };
gsap.fromTo(inner, { scale: level === 1 ? 1 : scaleFor(level - 1) }, { scale: scaleFor(level), ease: 'none', immediateRender: false, scrollTrigger: trig });
gsap.fromTo(shade, { opacity: level === 1 ? 0 : shadeFor(level - 1) }, { opacity: shadeFor(level), ease: 'none', immediateRender: false, scrollTrigger: { ...trig } });
}
// 3 · the arriving card's content settles in slightly after its frame
if (settle) 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' },
});
});
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
built = true;
}
build();
// Playground: rebuild (ctx.revert() unpins every card) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Stacking Cards on Scroll
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withStackingCardsonScroll(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `stk-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<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>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.stk-intro, .stk-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.stk-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.stk-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .9; }
.stk-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.stk { position: relative; padding: 0 4vw var(--knob-hold, 40vh); }
.stk__card { height: 80vh; padding-top: 16px; } /* room above the card 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);
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);
border: 1px solid var(--line);
box-shadow: 0 -12px 40px -24px rgba(0,0,0,.6);
transform-origin: top center; will-change: transform;
}
.stk__inner::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.025) 0 1px, transparent 1px 56px); }
.stk__num { font-family: var(--font-display); 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; text-align: left; }
.stk__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3.6vw, 3.5rem); letter-spacing: -0.03em; line-height: 1; }
.stk__copy { margin-top: 1rem; color: var(--muted); font-size: clamp(.95rem, 1.15vw, 1.1rem); line-height: 1.5; }
.stk__shade { position: absolute; inset: 0; background: var(--bg); opacity: 0; pointer-events: none; }
@media (max-width: 767px) {
.stk__card { height: 72vh; }
.stk__body { align-self: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.stk { padding-bottom: 10vh; }
.stk__card { height: auto; padding-top: 0; margin-bottom: 4vw; }
.stk__inner { height: auto; min-height: 50vh; will-change: auto; }
}
// Stacking Cards on Scroll — cards pin in sequence; each new arrival shrinks and dims the ones beneath it.
gsap.registerPlugin(ScrollTrigger);
const BASE_OFFSET = 16; // px from the viewport top for the first card's pin line
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 14, firstScale: 0.9, deeperStep: 0.04, shade: 0.55, hold: 40, settle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.stk');
const cards = gsap.utils.toArray('.stk__card');
let ctx, built = false;
function build() {
ctx?.revert();
section.style.setProperty('--knob-hold', K('hold') + 'vh'); // how long the finished stack holds (section padding-bottom)
const STEP = K('step'); // px added per card → the stepped edge of the stack
const FIRST_SCALE = K('firstScale'); // the card directly beneath the active one
const DEEPER_STEP = K('deeperStep'); // each level further down shrinks by this much more
const SHADE = K('shade'); // overlay opacity of the card directly beneath (deeper cards add 0.15 each, capped at 0.85)
const settle = K('settle');
const scaleFor = (level) => FIRST_SCALE - DEEPER_STEP * (level - 1); // level 1 = directly beneath
const shadeFor = (level) => Math.min(0.85, SHADE + 0.15 * (level - 1));
ctx = gsap.context(() => {
if (!built) gsap.from('.stk-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // cards simply stack in normal flow
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const step = mmCtx.conditions.mobile ? Math.round(STEP * 0.7) : STEP;
const pinLine = (i) => BASE_OFFSET + i * step;
cards.forEach((card, i) => {
// 1 · pin each card at its own stepped line until the whole section has scrolled through
ScrollTrigger.create({
trigger: card, start: () => `top ${pinLine(i)}px`, endTrigger: section, end: 'bottom bottom',
pin: true, pinSpacing: false, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
});
// 2 · as card i arrives (viewport bottom → its pin line) every card beneath shrinks one level and dims
for (let j = 0; j < i; j++) {
const level = i - j; // 1 = directly beneath the arriving card
const inner = cards[j].querySelector('.stk__inner');
const shade = cards[j].querySelector('.stk__shade');
const trig = { trigger: card, start: 'top bottom', end: () => `top ${pinLine(i)}px`, scrub: true, invalidateOnRefresh: true, markers: false };
gsap.fromTo(inner, { scale: level === 1 ? 1 : scaleFor(level - 1) }, { scale: scaleFor(level), ease: 'none', immediateRender: false, scrollTrigger: trig });
gsap.fromTo(shade, { opacity: level === 1 ? 0 : shadeFor(level - 1) }, { opacity: shadeFor(level), ease: 'none', immediateRender: false, scrollTrigger: { ...trig } });
}
// 3 · the arriving card's content settles in slightly after its frame
if (settle) 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' },
});
});
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
built = true;
}
build();
// Playground: rebuild (ctx.revert() unpins every card) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());