Scroll-Pinned Sticky Cards Deck (3D tilt-off)
Goal
Build a scroll-driven section that holds a deck of four stacked cards pinned in the middle of the viewport. As the user scrolls through the (long) pinned section, the front card flies straight up while tilting back in 3D perspective (rotationX 0→35deg), and the cards stacked behind it slide forward and scale up to take the front position — one card handed off per scroll segment, in a continuous scrubbed loop. Smooth scroll via Lenis. There is an intro panel above and an outro panel below the pinned deck.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework — plain Vite-style module imports:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Register once: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener.
Layout / HTML
Three full-viewport <section>s in order — an intro, the pinned card deck, an outro:
<section class="intro"><h1>Enter the Frame</h1></section>
<section class="sticky-cards">
<div class="card" id="card-1">
<div class="col"><p>Quiet Control</p><h1>Signal Drift</h1></div>
<div class="col"><img src="…card-img-1" alt="" /></div>
</div>
<div class="card" id="card-2"> … id="card-2" … </div>
<div class="card" id="card-3"> … </div>
<div class="card" id="card-4"> … </div>
</section>
<section class="outro"><h1>Loop Complete</h1></section>
- Four cards, ids
card-1…card-4. Each card has exactly two.colchildren: column 1 holds a small<p>eyebrow at the top and an<h1>title (the two are pushed apart top/bottom); column 2 holds a single<img>. - Neutral fictional copy. Suggested per card — eyebrow / title:
- "Quiet Control" / "Signal Drift"
- "Fluid Structures" / "Skyline Drift"
- "Wired Thought" / "Neural Assembly"
- "Silent Repetition" / "Learning Loop"
- Intro
<h1>"Enter the Frame", outro<h1>"Loop Complete".
Styling
Import fonts:
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100..900;1,100..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap");
Palette — four card background colors as CSS variables (all card text is #fff):
--base-1: #9d2fa9(magenta/purple) →#card-1--base-2: #daff22(acid lime) →#card-2--base-3: #ffdd33(warm yellow) →#card-3--base-4: #6b7847(muted olive) →#card-4- Deck section background:
#e3e3db(warm off-white/greige). Intro & outro sections have no special background (default/transparent).
Typography:
h1:text-transform: uppercase; font-family: "Barlow Condensed", sans-serif; font-size: 3rem; font-weight: 800; line-height: 1;p:text-transform: uppercase; font-family: "DM Mono", monospace; font-size: 0.9rem;
Global / reset:
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }
Sections & cards (this is the structure the effect hangs on):
section { position:relative; width:100%; height:100svh; overflow:hidden; }.intro, .outro { display:flex; justify-content:center; align-items:center; }.sticky-cards { background-color:#e3e3db; perspective:1000px; }— theperspective:1000pxon the deck container is essential; it is what makes the per-cardrotationXread as a real 3D tilt-back rather than a flat squash..sticky-cards .card { position:absolute; top:50%; left:50%; width:65%; height:60%; display:flex; justify-content:center; align-items:center; gap:1rem; padding:2.5rem; border-radius:1rem; color:#fff; transform-origin:center bottom; will-change:transform; }— all four cards are absolutely stacked at the same center point;transform-origin:center bottommatters so the tilt-back pivots from the card's bottom edge (it hinges away like a page)..card .col { flex:1; height:100%; }.card .col:nth-child(1) { display:flex; flex-direction:column; justify-content:space-between; padding:0.5rem; }(eyebrow top, title bottom).card .col:nth-child(2) { border-radius:0.75rem; overflow:hidden; }(rounded image frame)- Per-card background + stacking order (front-most card first):
#card-1 { background-color:var(--base-1); z-index:5; }#card-2 { background-color:var(--base-2); z-index:4; }#card-3 { background-color:var(--base-3); z-index:3; }#card-4 { background-color:var(--base-4); z-index:2; }
Note the cards are centered via CSS top/left:50%, but the actual −50%/−50% centering offsets are applied by GSAP (xPercent/yPercent, below), not CSS transforms.
GSAP effect (the important part — 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);
Lenis is driven by GSAP's ticker (default Lenis options otherwise), Lenis scroll events call ScrollTrigger.update, and lag smoothing is disabled so the scrub stays glued to scroll position.
Constants & card count
const cards = document.querySelectorAll(".sticky-cards .card"); // 4
const totalCards = cards.length; // 4
const segmentSize = 1 / totalCards; // 0.25 → one card handed off per 25% of scroll progress
const cardYOffset = 5; // yPercent step between stacked cards
const cardScaleStep = 0.075; // scale step between stacked cards
Initial stack pose (gsap.set per card, before the ScrollTrigger)
cards.forEach((card, i) => {
gsap.set(card, {
xPercent: -50,
yPercent: -50 + i * cardYOffset, // -50, -45, -40, -35
scale: 1 - i * cardScaleStep, // 1, 0.925, 0.85, 0.775
});
});
So at rest: the front card (i=0) is perfectly centered at full scale; each card further back is nudged down by 5 yPercent and shrunk by 0.075, peeking out below the one in front — a fanned deck seen from slightly above. (Because transform-origin is center-bottom and cards shrink, smaller cards sit lower and reveal a stepped stack.)
The ScrollTrigger (single pinned, scrubbed trigger — no timeline, all gsap.set inside onUpdate)
ScrollTrigger.create({
trigger: ".sticky-cards",
start: "top top",
end: `+=${window.innerHeight * 8}px`, // 8 viewport heights of scroll distance (computed once at load)
pin: true,
pinSpacing: true,
scrub: 1, // 1s smoothing between scroll and animation
onUpdate: (self) => { /* see below */ },
});
- The deck section pins at
top topand stays pinned for 8× the viewport height of scroll.pinSpacing:trueinserts a real spacer so the outro section appears only after the whole deck sequence finishes. scrub: 1(nottrue) adds ~1s of easing catch-up on top of Lenis, giving the hand-off a soft, weighted feel.- No tweens/timeline — the entire animation is recomputed each frame with
gsap.setcalls insideonUpdate, driven purely byself.progress.
Per-frame logic inside onUpdate(self)
const progress = self.progress; // 0 → 1 over the whole pin
// which card is the one currently flying out
const activeIndex = Math.min(Math.floor(progress / segmentSize), totalCards - 1); // 0..3
// 0→1 progress *within* the active card's segment
const segProgress = (progress - activeIndex * segmentSize) / segmentSize;
cards.forEach((card, i) => {
if (i < activeIndex) {
// already exited: parked off the top, tilted back
gsap.set(card, { yPercent: -250, rotationX: 35 });
} else if (i === activeIndex) {
// the current front card flying out
gsap.set(card, {
yPercent: gsap.utils.interpolate(-50, -200, segProgress), // rises up and off
rotationX: gsap.utils.interpolate(0, 35, segProgress), // hinges back in 3D
scale: 1, // locked at full size while exiting
});
} else {
// still in the stack behind: advance forward as segProgress grows
const behindIndex = i - activeIndex; // 1, 2, 3…
const currentYOffset = (behindIndex - segProgress) * cardYOffset;
const currentScale = 1 - (behindIndex - segProgress) * cardScaleStep;
gsap.set(card, {
yPercent: -50 + currentYOffset, // slides up toward center as segProgress → 1
rotationX: 0,
scale: currentScale, // scales up toward 1 as it reaches the front
});
}
});
Exact behavior to reproduce:
- Segments: progress 0–0.25 flies out card 0, 0.25–0.5 card 1, 0.5–0.75 card 2, 0.75–1.0 card 3.
segProgressis the normalized 0→1 position inside whichever segment is active. - Exiting (active) card:
yPercent −50 → −200(translates straight up by ~1.5 card-heights) whilerotationX 0 → 35deg(tilts its top edge away from the viewer, hinging on its bottom because oftransform-origin:center bottom). Itsscaleis pinned to1throughout the exit — it does not keep any per-index shrink, because by the time a card becomes active the previous segment has already promoted it to full scale. - Parked cards (index
< activeIndex, i.e. already gone): held atyPercent −250, rotationX 35— pushed a bit further up than the −200 exit end so they fully clear the frame. - Behind cards (index
> activeIndex): each advances one "slot" per segment. AssegProgressgoes 0→1,(behindIndex − segProgress)shrinks by 1, so the nearest behind card (behindIndex 1) moves fromyPercent −45 → −50andscale 0.925 → 1.0— landing exactly on the front pose right as the active card finishes exiting. The card two-back moves 0.925→0.85 equivalents forward one step, etc.rotationXstays 0 for all behind cards. - All interpolation is linear (
gsap.utils.interpolate= plain lerp); the only easing comes fromscrub: 1+ Lenis. Noease,duration,delay,stagger, SplitText, or CustomEase anywhere.
Net read: a continuous conveyor where the top card ramps up and hinges back into the distance while the deck below marches forward and grows to fill the vacancy, one card per quarter of the scroll.
Assets / images
Four card images, one per card, each rendered inside the card's second column at width:100%; height:100%; object-fit:cover; inside a rounded, overflow-hidden frame. The source files are square (1:1) but are object-fit:cover cropped to the column, so they read as a portrait-to-squarish slice (each column is about half the card's 65vw width and its full 60vh height).
They are a cohesive set of warm, sun-faded vintage-film retro-futurist photographs — 1950s/60s Kodachrome look, grainy, soft, dominated by cream/ivory whites, orange, amber and gold with muted accents. Subjects, in card order:
- card-img-1 — two figures in orange jumpsuits with white over-ear headphones, seen from behind at a retro control-panel console, gazing through a window at a starfield with a spiral galaxy and ringed planet (Saturn). Cream cabin, orange suits, deep-blue space.
- card-img-2 — a retro-futurist spaceport/building with tapered spires and domes in orange and cream, a vintage two-tone car parked out front, framed by autumn foliage under a hazy warm sky. Orange, cream, amber.
- card-img-3 — a person in a cream armchair reading an orange book, their head a transparent glass dome packed with colorful clockwork gears and machinery; sunlit room with bookshelf and lamp. Cream, orange, warm gold.
- card-img-4 — a young child writing at a wooden school desk in a sunlit vintage classroom, with a large chrome-and-glass space helmet resting on the desk and a globe nearby. Amber, honey-cream, muted teal chalkboard.
Any cohesive warm-toned retro-futurist photo set works; the shared cream/orange palette reads well against the vivid card backgrounds. Provide 4 files in card order; if fewer are available, repeat in order.
Behavior notes
- Desktop-first. At
max-width:1000pxthe cards restack vertically:.sticky-cards .card { width:calc(100% - 4rem); height:75%; flex-direction:column; }and.card .col { width:100%; }(image on top of text). The scroll/pin/tilt effect still runs unchanged on all sizes. - Section heights use
svhso mobile browser chrome doesn't clip the full-viewport panels;overflow:hiddenon sections keeps the off-screen exiting cards from creating scrollbars. - The
enddistance is captured once fromwindow.innerHeightat load, so it doesn't reflow on resize (matches the original). - No reduced-motion handling in the original — the animation is entirely scroll-scrubbed, so nothing autoplays; it only moves as the user scrolls.