Sticky Cards ScrollTrigger — Pinned Full-Screen Deck That Shrinks Each Image
Goal
Build a full-screen, scroll-driven image gallery. A hero heading and a stack of full-viewport image cards are each pinned in place one after another with ScrollTrigger (pin, pinSpacing:false, scrub), so every card holds fixed while the next section scrolls up and covers it. As a card gets covered, its centered image scales down from 1 to 0.5 (a fixed 1000×700 frame shrinking to half). The giant hero headline fades its opacity from 1 to 0 across the first 400vh of scroll. The last card scrolls normally (not pinned), then a footer releases the whole pinned stack. The signature effect is the layered "sticky cards" stack with each image quietly shrinking as it's buried.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. No other plugins, no Lenis, no framework — plain Vite-style module imports:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
Register once: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener. Native scroll (no smooth-scroll library).
Layout / HTML
A .container wraps a fixed logo, a hero section, six card sections, and a footer. Class names are load-bearing — the JS queries .pinned, .card.scroll, .img, .footer, and .hero h1.
<div class="container">
<div class="logo"><a href="#">Flow Canvas</a></div>
<section class="hero pinned">
<h1>Sculpted Zen <br />Playground</h1>
</section>
<section class="card pinned"><div class="img"><img src="…img1" alt="" /></div></section>
<section class="card pinned"><div class="img"><img src="…img2" alt="" /></div></section>
<section class="card pinned"><div class="img"><img src="…img3" alt="" /></div></section>
<section class="card pinned"><div class="img"><img src="…img4" alt="" /></div></section>
<section class="card pinned"><div class="img"><img src="…img5" alt="" /></div></section>
<section class="card scroll"><div class="img"><img src="…img6" alt="" /></div></section>
<section class="footer"><h1>Footer</h1></section>
</div>
<script type="module" src="./script.js"></script>
Structure notes — these are what the effect hangs on:
.pinnedmatches 6 elements: the hero (class="hero pinned") plus the five image cards (class="card pinned"). The hero is treated as a pinned section too, even though it has no.imgchild (only an<h1>).- The sixth/last card is
class="card scroll"(NOT.pinned) — it scrolls normally and is never pinned or scaled. - Every image card is
section.card > div.img > img. The.imgdiv is the animated element (the image scales via its wrapper). - Copy is fictional demo text: logo "Flow Canvas", hero "Sculpted Zen / Playground" (two lines via
<br>), footer "Footer".
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
Palette & type:
- Page background
#000(pure black); all text#fff. - Font family: a neutral sans-serif at weight 400 for everything (original references a proprietary face named "PP Acma" — any clean grotesque/sans is fine).
html, body { width:100%; height:100%; font-family:"PP Acma", sans-serif; background:#000; }
img { width:100%; height:100%; object-fit:cover; }
.container { width:100%; height:100%; }
section { width:100vw; height:100vh; } /* every section is a full viewport */
- Logo — fixed banner, top-center, above everything:
``css .logo { position:absolute; top:0; left:50%; transform:translateX(-50%); padding:2em; z-index:2; } .logo a { text-decoration:none; font-size:24px; font-weight:400; color:#fff; } ``
- Hero heading — huge centered display type:
``css .hero h1 { position:absolute; width:100%; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; font-weight:400; font-size:200px; color:#fff; letter-spacing:-8px; line-height:90%; } ``
- The image frame — a fixed-size box centered in each card, centered with a 3D-safe transform (important, GSAP scales it):
``css .img { position:absolute; top:50%; left:50%; transform:translate3d(-50%,-50%,0); width:1000px; height:700px; /* ~10:7 landscape, object-fit:cover */ } ``
- Last (scroll) card gets
position:relative;—.card.scroll { position:relative; }. (The pinned cards keep the default static position; only the scrolling card is made relative.) - Footer — half-height, flex-centered:
``css .footer { width:100%; height:50vh; display:flex; justify-content:center; align-items:center; color:#fff; } .footer h1 { font-size:48px; font-weight:400; letter-spacing:-2px; } ``
The document is naturally tall: 7 sections at 100vh (hero + 6 cards) + a 50vh footer ≈ 750vh of native scroll. pinSpacing:false (below) means the pins do not add extra scroll length.
GSAP effect (be exhaustive — this is the core)
Everything runs inside document.addEventListener("DOMContentLoaded", …) after gsap.registerPlugin(ScrollTrigger).
References
const footer = document.querySelector(".footer");
const lastCard = document.querySelector(".card.scroll");
const pinnedSections = gsap.utils.toArray(".pinned"); // 6: hero + 5 image cards
Per-pinned-section loop — TWO ScrollTriggers per section
pinnedSections.forEach((section, index, sections) => { … }). For each of the 6 pinned sections:
let img = section.querySelector(".img"); // hero → null (GSAP tolerates a null target, no-op)
let nextSection = sections[index + 1] || lastCard; // the section that will cover this one
let endScalePoint = `top+=${nextSection.offsetTop - section.offsetTop} top`; // = one viewport height of scroll
(A) The pin trigger — a target-less gsap.to whose only job is to attach a pin (it animates no properties):
gsap.to(section, {
scrollTrigger: {
trigger: section,
start: "top top",
end: index === sections.length
? `+=${lastCard.offsetHeight / 2}` // dead branch — index never equals length
: footer.offsetTop - window.innerHeight, // effective end for ALL pinned sections
pin: true,
pinSpacing: false,
scrub: 1,
},
});
start:"top top"— the section pins the moment its top reaches the top of the viewport.endis a bare number (footer.offsetTop - window.innerHeight, ≈ 600vh in px). A numeric ScrollTriggerendis an absolute page scroll position, not relative — so every pinned section releases at that same scroll point (when the footer's top reaches the bottom of the viewport). Hero pins from scroll 0→600vh; card 1 from 100vh→600vh; … card 5 from 500vh→600vh. (The ternary's true branch is unreachable —indexmaxes atsections.length - 1— so all six use the absolute end.)pinSpacing:falseis essential: no spacer is inserted, so the next section scrolls up over the pinned one instead of pushing content down. This is what makes the cards physically stack/overlap. Later DOM = painted on top, so each new card covers the previous.scrub:1→ ~1s of smoothing catch-up between scroll position and the pin.
(B) The image-scale trigger — a separate scrubbed fromTo shrinking the .img wrapper:
gsap.fromTo(
img,
{ scale: 1 },
{
scale: 0.5,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: endScalePoint, // "top+=<Δ> top" where Δ = distance to next section ≈ 100vh
scrub: 1,
},
}
);
- Animates
scale: 1 → 0.5on the.imgbox (linear,ease:"none"), scrubbed over exactly the scroll span during which the next section rises up to cover this one (endScalePoint= the pixel gap to the next section's top = one viewport height). - Because
.imgis centered withtranslate3d(-50%,-50%,0), the scale collapses toward the card's center. The 1000×700 frame becomes 500×350 by the time it's fully buried, then stays frozen at 0.5 while the section remains pinned (pin lasts to 600vh, far past the scale's 100vh span). - The hero section's
imgisnull, so its scale tween is a harmless no-op — the hero only fades (below). The last card (.card.scroll) is not in.pinned, so it never scales and never pins; it scrolls past at full size.
Hero heading opacity fade (independent trigger)
const heroH1 = document.querySelector(".hero h1");
ScrollTrigger.create({
trigger: document.body,
start: "top top",
end: "+=400vh",
scrub: 1,
onUpdate: (self) => {
heroH1.style.opacity = 1 - self.progress; // 1 → 0 across the first 400vh of scroll
},
});
- A body-level ScrollTrigger spanning the first 400vh of scroll. Its
onUpdatewrites the hero<h1>'s inlineopacitydirectly (no tween) as1 - progress, so the headline linearly fades from fully visible to invisible while the first cards begin stacking over the hero.scrub:1smooths it.
Net choreography (top → bottom scroll)
- Hero is pinned; its 200px headline fades 1→0 over the first 400vh while card 1 scrolls up to cover it.
- Each image card, as it reaches the top, pins; the previous card's image has already shrunk toward 0.5 as this new card rose over it. The stack keeps building — up to 6 sections pinned simultaneously, each frozen at its final scale.
- The 6th card (
.card.scroll) is NOT pinned — it scrolls normally over the pinned stack at full 1000×700 size. - When the footer's top reaches the viewport bottom (scroll ≈ 600vh) all pins release together, and the footer ("Footer", 50vh) scrolls into view.
- All motion is linear and scrubbed — the only smoothing is
scrub:1. No timelines, labels, stagger, delay, SplitText, CustomEase, lerp/rAF loops, or Three.js. Just per-section pin +scale 1→0.5, plus the hero opacity fade.
Assets / images
6 images, one per card, each shown inside the fixed 1000×700 px (~10:7 landscape) .img frame with object-fit:cover. Use a cohesive, minimal/editorial set — the effect reads best with quiet, well-composed images (e.g. full-bleed editorial street/fashion portraits and soft abstract color-gradient/texture fields). No brands, logos, or text in the images.
img1–img5→ the five pinned cards (these are the ones that scale down as they're covered).img6→ the final scrolling card (stays full size).
If fewer source images are available, repeat them in order; if more, use the first six.
Behavior notes
- Desktop-first, native scroll. No Lenis/smooth-scroll and no reduced-motion branch in the original — nothing autoplays; every motion is scroll-scrubbed, so it only moves as the user scrolls.
- Full-viewport sections. Keep each
sectionatwidth:100vw; height:100vh(footer 50vh). The tall document (~750vh) pluspinSpacing:falsesupplies the scroll distance; do not add extra spacers. pinSpacing:false+ numeric absoluteendare the crux — together they make all pinned sections release at one shared scroll position and let later cards overlap earlier ones. Changing either breaks the stacking look.- The hero has no image, so its scale tween targets
null— this is intentional/expected (GSAP just skips it); do not add an.imgto the hero. - Values are computed once at load from
offsetTop/window.innerHeight; the original does not recompute on resize.