L1

·

Scroll · sticky

Sticky stacking cards

Clients ask for this by pointing at someone else's site. Almost everyone builds it with position: sticky and stops there — which gives you cards that replace each other with no sense of a pile. The two additions that sell it are a staggered top offset so each card parks 16px lower and you can see the edges of the stack, and a scrubbed scale-down plus dim overlay so a covered card visibly recedes. The one thing to get right is the end of each trigger: it must be pinned to the last card's arrival, not to the card's own exit, or cards keep shrinking long after nobody can see them.

Open the live demo →

How to build

Panel + script

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

ScrollTrigger · matchMedia

Build time

40 min. Most of it spent on the sticky parent.

No-code?

Sticky is CSS, so the stack itself needs no code at all. The scale/dim scrub is one “While scrolling in view” interaction per card in the panel — workable for four cards, miserable for ten. This script is the same thing, once.

01

Webflow structure

element · class · attribute

<b>Section</b> <i>section_stack</i> <b>Div</b> <i>stack_list</i> ← NO overflow property. None. See gotchas. <b>Div</b> <i>stack_item</i> ← position: sticky (top set by the script) <b>Div</b> <i>stack_card</i> ← transform-origin: 50% 0% <b>Div</b> <i>stack_card-dim</i> ← absolute inset 0, opacity 0, z-index 3 <b>Div</b> <i>stack_card-body</i> <b>Text</b> <i>stack_card-no</i> <b>Heading H3</b> <i>stack_card-title</i> <b>Paragraph</b> <i>stack_card-body-text</i> <b>Div</b> <i>stack_card-media</i> <b>Image</b> <b>Div</b> <i>stack_item</i> … (repeat, or one Collection Item)

02

Styles to set in the Designer

stack_item | <code>position: sticky</code>. Leave <code>top</code> empty in the Designer — the script writes it per card so the ledge stagger works at any viewport height. stack_item (2nd onward) | <code>margin-top: 3.5rem</code>. That margin is what gives the scroll its length; without it the cards arrive on top of each other instantly. stack_card | <code>transform-origin: 50% 0%</code>. Scaling from the centre pulls the card's top edge away from its ledge and the stack falls apart visually. This one property is the whole illusion. stack_card-dim | Absolute, inset 0, background matching the page, <code>opacity: 0</code>, <code>pointer-events: none</code>, above the content. Dimming with an overlay is far cheaper than animating a <code>filter</code>. stack_list | Bottom padding of ~40vh so the last card can reach its ledge before the section ends.

03

Building it with the Interactions panel

no code, free plan
  1. The stack itself needs no interaction — set stack_item to position: sticky with a staggered top value per card (16px more each) directly in the Designer's style panel.
  2. For the scale/dim: select each stack_card except the last → Interactions → trigger While scrolling in view, scoped from this card's ledge to the last card's ledge → animate Scale 100% → 90% and stack_card-dim Opacity 0 → 55%, both Ease None.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • One interaction per card, built by hand — workable for four cards, as the site itself notes, but miserable for ten, since each one needs its own manually-tuned start/end range instead of the script's shared ledge math.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const items = gsap.utils.toArray(".stack_item"); if (!items.length) return; const LEDGE = 16; // px of card left visible const base = () => Math.min(innerHeight * 0.08, 80); // where card 1 parks gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => { const top = base(); items.forEach((item, i) => { // Each card parks a little lower than the last, so you can see the pile. item.style.top = (top + i * LEDGE) + "px"; if (i === items.length - 1) return; // the top card never shrinks const card = item.querySelector(".stack_card"); const dim = item.querySelector(".stack_card-dim"); gsap.timeline({ scrollTrigger: { trigger: item, start: () => "top " + (top + i * LEDGE), // THE detail: stop when the LAST card lands, not when this card // leaves. Otherwise cards keep shrinking after they are covered // and the bottom of the stack ends up tiny. endTrigger: items[items.length - 1], end: () => "top " + (top + (items.length - 1) * LEDGE), scrub: true, invalidateOnRefresh: true // re-measure on resize } }) .to(card, { scale: 0.9, ease: "none" }, 0) .to(dim, { opacity: 0.55, ease: "none" }, 0); }); }); }); </script>

07

Gotchas that will cost you an hour

  1. One overflow: hidden anywhere above the list kills sticky. Silently. No error, no warning, the cards just scroll normally. Check every parent — and check body, because a marquee section elsewhere on the page is the usual culprit.
  2. transform-origin: 50% 0% on the card, not the item. Scale from the centre and the top edge lifts off its ledge; the stack stops looking like a stack and starts looking like a bug.
  3. Sticky needs room to be sticky in. A sticky element only travels as far as its parent's remaining height. If stack_item is the same height as the card with no margin, there is nothing to stick through.
  4. Do not pin. This is native sticky — adding ScrollTrigger's pin: true creates a second, conflicting mechanism and you get a fight over transforms.
  5. Use invalidateOnRefresh and function-based start/end. Without it the ledge maths is frozen at load and everything is wrong after a resize or a font swap.
  6. Five cards is the ceiling. Past that the scroll distance is longer than anyone's patience and the shrink ratio makes card one unreadable. If the client wants nine, that is two sections.
08

Push it further

  • Add a tiny alternating rotate (±1.5°) to the scrub — it reads as physical paper rather than as UI.
  • Drive a progress counter (“02 / 05”) from the same timeline so the section tells the user where they are in the stack.
  • Swap the last card for a CTA that scales up slightly as it lands. The pattern break is what makes people click it.

Webflow Motion Lab — GSAP 3.15, all plugins free.

12

/ 30