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.
Images
This component ships with 6 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/sticky-cards-2/img1.jpg
https://motionprompts.dev/c/sticky-cards-2/img2.jpg
https://motionprompts.dev/c/sticky-cards-2/img3.jpg
https://motionprompts.dev/c/sticky-cards-2/img4.jpg
https://motionprompts.dev/c/sticky-cards-2/img5.jpg
https://motionprompts.dev/c/sticky-cards-2/img6.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--ink,--muted,--line,--bg. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
Adapting this to React
Everything above describes a standalone document: one script that waits for DOMContentLoaded, reaches into the page with plain document.querySelector and gsap.utils.toArray, and wires up thirteen ScrollTrigger instances from that single run — two per pinned section (the pin and the .img scale tween) across the six .pinned elements, plus the body-level trigger driving the hero fade — and never has to undo any of them, because the tab reloads long before a second run could collide with the first.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. A double run here doesn't just double one trigger, it doubles the whole stack: two pins on each of the six sections disagreeing about the same scrub, two .img scale tweens fighting over the same scale property, and two body-level triggers both writing into heroH1.style.opacity from their own copy of self.progress. The visible symptom is stutter in the pin handoff between cards and a headline that flickers between two fade curves, and none of it reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script subscribes straight to DOMContentLoaded with no readyState guard in front of it. By the time a React component mounts, that event has already fired, so a listener attached this way is simply never invoked — no pin, no scale, no fade, nothing to debug. Delete the document.addEventListener("DOMContentLoaded", …) wrapper and move its body — the pinnedSections.forEach(...) loop that builds the pin-and-scale pair for each section, and the trailing ScrollTrigger.create for the hero fade — into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can stay exactly where it is, at module scope.
*(2) Element lookups* — .footer, .card.scroll (lastCard) and .hero h1 are each fetched off document directly, and gsap.utils.toArray(".pinned") walks the whole document for its six matches too. Give the outer .container a root ref and resolve all four the same way, off that ref: rootRef.current.querySelector(".footer"), rootRef.current.querySelector(".card.scroll"), rootRef.current.querySelector(".hero h1"), and Array.from(rootRef.current.querySelectorAll(".pinned")) in place of the global toArray. section.querySelector(".img") inside the forEach loop is already scoped to section and needs no change. This is not a style nit here specifically: during the StrictMode remount two copies of the six-section stack exist for an instant, and .pinned or .card.scroll resolving against the outgoing copy means the pin, the scale tween and the offset math (nextSection.offsetTop, footer.offsetTop) all get built against elements that are about to be removed.
*(3) Cleanup* — Wrap the forEach loop and the hero-fade trigger in one gsap.context scoped to the root ref, and revert it on cleanup:
useEffect(() => {
const ctx = gsap.context(() => {
const footer = rootRef.current.querySelector(".footer");
const lastCard = rootRef.current.querySelector(".card.scroll");
const pinnedSections = Array.from(rootRef.current.querySelectorAll(".pinned"));
const heroH1 = rootRef.current.querySelector(".hero h1");
// pinnedSections.forEach(...) building the pin trigger and the .img scale
// tween exactly as above, then the body-level ScrollTrigger.create for heroH1
}, rootRef);
return () => ctx.revert();
}, []);
Without the revert, the remount leaves the second copy's twelve per-section triggers stacked on the first's — same six sections, same numeric end computed from footer.offsetTop, both still scrubbing against the same scroll.
One write in this effect sits outside what ctx.revert() can see. The hero fade doesn't animate heroH1 through a tween — it sets heroH1.style.opacity = 1 - self.progress directly inside the trigger's onUpdate. ctx.revert() restores properties GSAP itself animated back to their pre-effect values; a bare element.style.x = … assignment was never GSAP's to track. On the StrictMode remount, the first effect's trigger is killed mid-scroll, and whatever opacity it last wrote — anywhere between fully visible and fully transparent, depending on where the scroll position happened to be — stays painted on the headline until the second effect's own onUpdate next fires on a scroll event. Route it through gsap.set(heroH1, { opacity: 1 - self.progress }) instead of the raw assignment: same visible fade, but now the value is part of GSAP's own record of the element, and ctx.revert() restores it along with the pins and the scale tweens.