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.
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.
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)
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.
Building it with the Interactions panel
no code, free plan- 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.
- 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.
Custom attributes
Element settings → Custom attributesCSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- 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>
Gotchas that will cost you an hour
- 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.
- 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.
- 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.
- Do not pin. This is native sticky — adding ScrollTrigger's pin: true creates a second, conflicting mechanism and you get a fight over transforms.
- 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.
- 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.
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