Sticky Stacking Service Cards on Scroll
Goal
Build a scroll-driven page where four full-width service cards each pin at 35% of the viewport as you scroll and stack up under the next incoming card, while every pinned card's inner wrapper is scrubbed upward by a decreasing multiple of 14vh — producing a layered "peel" as later cards slide over earlier ones. A centered intro headline stays pinned (with no pin spacing) for the whole duration of the stack, from the first card until the last. Smooth scroll via Lenis. The star effect is the per-card pin + scrubbed inner-translate stack, with the headline pin overlapping it.
Tech
Vanilla HTML/CSS/JS with ES module imports, built under Vite (npm). Use:
gsap(npm) with the single pluginScrollTrigger(gsap/ScrollTrigger), registered once viagsap.registerPlugin(ScrollTrigger).lenis(npm) for smooth scroll, wired into GSAP's ticker.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
No framework, no SplitText, no CustomEase, no Three.js. All logic runs at module top level (no DOMContentLoaded wrapper needed since the script is a deferred module).
Layout / HTML
One wrapper div.app containing five siblings in this exact order:
<div class="app">
<section class="hero"><img src="…hero" alt="" /></section>
<section class="intro">
<h1>Creating standout brands for startups that bring joy and leave lasting impressions.</h1>
</section>
<section class="cards">
<div class="card" id="card-1">
<div class="card-inner">
<div class="card-content">
<h1>Brand Foundation</h1>
<p>The heart of your company's story. It shapes your vision, values, and voice, ensuring a clear and powerful impact in every, interaction.</p>
</div>
<div class="card-img"><img src="…card-1" alt="Brand Foundation" /></div>
</div>
</div>
<div class="card" id="card-2"> … Design Identity … </div>
<div class="card" id="card-3"> … Digital Presence … </div>
<div class="card" id="card-4"> … Product Design … </div>
</section>
<section class="outro"><h1>Let's build a brand that leaves a mark.</h1></section>
</div>
- Four cards, ids
card-1…card-4. Each.cardwraps a.card-innerthat holds two children: a.card-content(an<h1>title + a<p>paragraph) and a.card-img(a single<img>). - Card titles / paragraphs (neutral agency copy):
- Brand Foundation — "The heart of your company's story. It shapes your vision, values, and voice, ensuring a clear and powerful impact in every, interaction."
- Design Identity — "Your brand's visual fingerprint. It crafts a distinctive look that sparks recognition and builds emotional connections with your audience."
- Digital Presence — "Our web solutions combine cutting-edge design and seamless functionality to create experiences that captivate and inspire your audience."
- Product Design — "We craft user-first products that are both functional and visually appealing, delivering solutions that leave a lasting impression."
- Intro
<h1>: "Creating standout brands for startups that bring joy and leave lasting impressions." - Outro
<h1>: "Let's build a brand that leaves a mark."
Styling
Fonts: load Geist and Geist Mono from Google Fonts (family=Geist:wght@100..900&family=Geist+Mono:wght@100..900). Use Geist as the page sans-serif (the body font-family). No monospace is actually rendered; Geist is what shows.
Global reset & base:
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }h1 { font-size:4rem; font-weight:600; line-height:1; margin-bottom:2.5em; }p { font-size:1.25rem; font-weight:500; }
Full-viewport panels:
.hero, .intro, .outro { position:relative; width:100vw; height:100vh; padding:2em; }.hero { padding:0; }(image is full-bleed)..intro, .outro { background-color:#fff; display:flex; align-items:center; }.intro h1, .outro h1 { margin-bottom:0; }
Cards (this is the geometry the effect hangs on):
.card { position:relative; }— no explicit height; each card's height is content-driven..card-inner { position:relative; will-change:transform; width:100%; height:100%; padding:2em; display:flex; gap:4em; }— the.card-inneris the element that gets translated by GSAP (not the.card), andwill-change:transformis set on it..card-content { flex:3; }(text column, ~3/4 width).card-img { flex:1; aspect-ratio:16/9; border-radius:0.75em; overflow:hidden; }(image column, ~1/4 width, rounded, clipped)
Per-card .card-inner background colors (the color lives on .card-inner, not .card):
#card-1 .card-inner { background-color:#c3abff; }(lilac / light purple)#card-2 .card-inner { background-color:#ffffff; }(white)#card-3 .card-inner { background-color:#fed35b; }(warm yellow)#card-4 .card-inner { background-color:#1e1e1e; color:#fff; }(near-black, white text)
Because cards are later in DOM order, each incoming card paints on top of the previous ones — so as cards pin and stack, card-4 ends visually on top.
GSAP effect (be exhaustive)
Smooth scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Default Lenis options. Lenis scroll events drive ScrollTrigger.update, GSAP's ticker drives Lenis' raf, and lag smoothing is disabled so scrub stays glued to scroll.
Collect cards
const cards = gsap.utils.toArray(".card"); // [card-1, card-2, card-3, card-4], length 4
1) Pin the intro headline across the whole stack (no pin spacing)
A single ScrollTrigger pins the .intro section in place while all the cards scroll and stack:
ScrollTrigger.create({
trigger: cards[0], // card-1
start: "top 35%", // pin begins when card-1's top reaches 35% of viewport
endTrigger: cards[cards.length - 1], // card-4 (last card)
end: "top 30%", // pin releases when card-4's top reaches 30% of viewport
pin: ".intro",
pinSpacing: false, // NO spacer — following content keeps scrolling under the fixed headline
});
The intro headline (which sits directly above the cards in the DOM) freezes in place the moment card-1 climbs to the 35% line and stays frozen until card-4 nearly arrives (its top at 30%). With pinSpacing:false, no extra scroll length is inserted — the cards scroll up over/under the pinned headline.
2) Per-card pin + scrubbed inner-translate (the stack)
Loop every card. Skip the last card (index === cards.length - 1) — card-4 is never pinned and never animated; it is the final card that lands on top of the stack. For each of the first three cards (card-1, card-2, card-3):
a) Pin the card (no pin spacing):
ScrollTrigger.create({
trigger: card,
start: "top 35%", // this card pins when its top reaches 35% of viewport
endTrigger: ".outro",
end: "top 65%", // stays pinned until the outro's top reaches 65% of viewport
pin: true,
pinSpacing: false, // no spacer → each card pins at the same 35% line and overlaps the previous pinned cards
});
Because every card pins at the identical top 35% line with pinSpacing:false, they accumulate stacked at that line — a new card slides up and pins over the ones already held, building the deck.
b) Scrub the card's inner wrapper upward:
gsap.to(card.querySelector(".card-inner"), {
y: `-${(cards.length - index) * 14}vh`, // negative → moves UP, in vh units
ease: "none", // linear; only easing comes from scrub + Lenis
scrollTrigger: {
trigger: card,
start: "top 35%", // same start as the pin
endTrigger: ".outro",
end: "top 65%", // same end as the pin — all inners finish together at outro top 65%
scrub: true, // tied 1:1 to scroll position
},
});
Exact y end values ((cards.length - index) * 14vh, with cards.length === 4):
card-1(index 0):y: -56vhcard-2(index 1):y: -42vhcard-3(index 2):y: -28vhcard-4(index 3): no tween (last card)
Each .card-inner slides from y: 0 to its negative target over its own scrub window. Earlier cards travel farther up (−56 → −42 → −28) and, because they reach the 35% line sooner but all share the same end (outro top 65%), they animate over a longer scroll span. The net read: as each incoming card pins over the one below it, the covered card's content drifts upward and peeks past the top edge of the newcomer — a continuous layered peel, with the dark card-4 settling on top at the end.
Values summary
- Trigger: scroll only (ScrollTrigger + Lenis). No load/hover/click/mousemove.
- Animated property:
y(translateY) of each.card-inner, invh. Start0→ end−56vh / −42vh / −28vhfor cards 1/2/3. - ease:
"none"(linear) on the tweens. scrub:trueon the inner-translate triggers. - Pins: intro pinned
card-1 top 35%→card-4 top 30%; cards 1–3 each pinnedtop 35%→outro top 65%. All pins usepinSpacing:false. - No duration/delay/stagger/timeline labels, no SplitText, no CustomEase, no lerp/rAF loop of your own (Lenis' internal rAF is the only one). No
gsap.setinitial poses.
Assets / images
- 1 hero image — a full-viewport, full-bleed background filling
.hero(width:100%; height:100%; object-fit:cover). A landscape (~3:2) black-and-white studio photograph: a lone folding chair spotlit at center on a seamless cyclorama backdrop, deep-black surrounds and soft grey floor — a moody, high-contrast greyscale editorial scene. - 4 card images — one per card, each rendered inside a 16:9 rounded, overflow-hidden frame occupying roughly the right quarter of the card (
.card-img,flex:1,object-fit:cover; portrait/square sources are center-cropped to the frame). Four cohesive editorial / product photographs, provided in card order (repeat in order if fewer are available). No brand logos: - Lilac card — a landscape (16:9) monochrome purple flat-lay of streetwear laid out on a deep-violet surface (sneakers, folded white tee, shoulder bag, cap, jacket); dominant tones lilac and purple with white accents.
- White card — a portrait product photograph of a single cream-and-terracotta sneaker floating among red and white flowers; warm blush background, dominant colors cream, deep red and soft pink.
- Yellow card — a grainy black-and-white portrait crop: the collar and neck of a person in a dark blazer against a pale grey wall, face out of frame; high-contrast greyscale.
- Dark card — a light grey-and-white marble / fluid texture, soft flowing swirls with faint darker veining; near-monochrome white-and-grey abstract.
Behavior notes
- Desktop-first. At
max-width:900px:h1margin-bottom becomes4rem;pfont-size becomes1rem;.card-innerswitches toflex-direction:column; and.card-img { display:none; }— the image column is hidden entirely on small screens, so cards show text only. The pin/scrub stack still runs. - Everything is scroll-scrubbed — nothing autoplays, so motion only occurs while scrolling. No explicit reduced-motion handling in the original.
- Section panels use
100vh; cards are content-height. The whole effect relies onpinSpacing:falseeverywhere so the pinned elements overlap instead of pushing the page longer.