Scroll-Pinned Horizontal Heading + Fly-Across Product Cards
Goal
Build a scroll-driven, pinned section where a giant single-line heading slides horizontally to the left as you scroll, while five product cards fly in from off the right edge, travel all the way across the screen and off the left, each one staggered by a scroll delay and following its own hand-authored path of vertical bob + rotation. The whole section is pinned for 5 viewport heights of scroll, smooth-scrolled with Lenis. The star effect is the interplay between the massive horizontally-panning title and the swarm of cards arcing across it, all scrubbed 1:1 to scroll via a single ScrollTrigger.onUpdate that recomputes every element per frame with gsap.utils.interpolate. A full-bleed hero image sits above the pinned section and a plain dark outro sits below it.
Tech
Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). Use gsap (npm) plus the single GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework, no SplitText/CustomEase/Three.js.
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
Order top to bottom: an absolutely-positioned <nav>, a full-viewport hero, the pinned sticky section (contains the giant header + five cards), then a full-viewport outro. Class names are load-bearing (JS queries .sticky, .sticky-header, .card).
<nav>
<div class="logo"><a href="#">Nebulon</a></div>
<div class="nav-items">
<a href="#">Catalog</a>
<a href="#">Cart</a>
</div>
</nav>
<section class="hero"></section>
<section class="sticky">
<div class="sticky-header">
<h1>Nebulon Does it again.</h1>
</div>
<!-- 5 cards, all identical structure -->
<div class="card">
<div class="card-img"><img src="…img1" alt="" /></div>
<div class="card-content">
<div class="card-title"><h2>Immersive Training Simulations</h2></div>
<div class="card-description">
<p>Revolutionize hands-on learning with lifelike training environments, enhancing skill development and retention.</p>
</div>
</div>
</div>
<!-- card 2 … card 5 -->
</section>
<section class="outro"><p>(Your next section goes here)</p></section>
<script type="module" src="./script.js"></script>
- Exactly five
.cards, each:.card-img > img, then.card-contentholding.card-title > h2and.card-description > p. - "Nebulon" is the fictional demo brand — use it for the nav logo and heading; no real client/brand names.
- Neutral feature copy per card (title / description):
- Immersive Training Simulations — "Revolutionize hands-on learning with lifelike training environments, enhancing skill development and retention."
- Virtual Design Collaboration — "Enable remote teams to co-create in 3D spaces, speeding up design iterations and boosting innovation."
- Immersive Product Demos — "Showcase products in a fully interactive, 360-degree experience, making presentations more engaging and memorable."
- Remote Healthcare Solutions — "Empower healthcare professionals with virtual consultations and remote diagnostics in immersive 3D environments."
- Interactive Entertainment — "Deliver a new dimension of gaming and entertainment with fully immersive and interactive virtual experiences."
- Outro paragraph text: "(Your next section goes here)".
Styling
Font: a light serif display face (original uses "Apple Garamond Light"; any thin Garamond-style serif is fine as a fallback — the look is thin, wide, editorial). Set it on html, body. Note headings use font-weight: lighter.
Palette:
- Sticky section background:
#ded8c8(warm greige / bone). - Cards: background
#000, all card text#fff. - Nav links & body text:
#000. - Outro: background
#000, its<p>text#ded8c8. - Hero: a full-bleed background photo (
background-size: cover; no-repeat 50% 50%).
Global / reset & document height:
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }html, body { width:100vw; height:800vh; }— the tall800vhbody guarantees the scroll runway; it lines up with the pin spacer (see effect). Keep it.section { width:100vw; height:100vh; overflow:hidden; }— every section is one viewport tall;overflow:hiddenclips the off-screen cards and the over-wide header so they never create scrollbars.
Nav:
nav { position:absolute; top:0; width:100vw; padding:1em; display:flex; justify-content:space-between; align-items:center; }.logo, .nav-items { flex:1; }and.nav-items { display:flex; justify-content:center; gap:2em; }nav a { text-decoration:none; color:#000; font-size:24px; letter-spacing:-0.02em; }
Sticky section & the giant header (critical geometry):
.sticky { position:relative; background-color:#ded8c8; }(height comes fromsection= 100vh)..sticky-header { position:absolute; top:0; left:0; width:250vw; height:100%; display:flex; justify-content:center; align-items:center; will-change:transform; }— the header wrapper is 2.5 viewport-widths wide; that extra width is the horizontal travel distance the JS pans through..sticky-header h1 { margin:0; color:#000; font-size:30vw; font-weight:lighter; letter-spacing:-0.05em; line-height:100%; }— one enormous line,30vwtall.
Cards (critical geometry — the animation is expressed in % of these dimensions):
.card { position:absolute; top:10%; left:100%; width:325px; height:500px; background-color:#000; border-radius:1em; padding:0.5em; will-change:transform; z-index:2; }— fixed pixel size 325×500, anchored attop:10%; left:100%so each card's default origin is just past the right edge of the viewport. All five stack at the same origin; the JS pulls them apart via transforms..card .card-img { width:100%; height:200px; border-radius:0.5em; overflow:hidden; }.card-content { width:100%; height:275px; display:flex; flex-direction:column; justify-content:space-between; color:#fff; padding:0.5em; }.card-content h2 { font-size:42px; font-weight:lighter; letter-spacing:-0.005em; }.card-content p { font-size:20px; font-weight:lighter; letter-spacing:-0.005em; }
Outro: .outro { display:flex; justify-content:center; align-items:center; background-color:#000; }, .outro p { color:#ded8c8; font-size:30px; letter-spacing:-0.005em; }.
Responsive: only tweak is @media (max-width:900px){ .nav-items{ justify-content:flex-end; } }.
Include the standard Lenis helper CSS (.lenis.lenis-smooth{scroll-behavior:auto !important;}, .lenis.lenis-stopped{overflow:hidden;}, etc.).
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);
Default Lenis options. Lenis' rAF is driven by GSAP's ticker; Lenis fires ScrollTrigger.update on scroll; lag smoothing off. All smoothing comes from Lenis — the ScrollTrigger below has NO scrub.
Elements & constants
const stickySection = document.querySelector(".sticky");
const stickyHeader = document.querySelector(".sticky-header");
const cards = document.querySelectorAll(".card"); // 5
const stickyHeight = window.innerHeight * 5; // pin distance = 5 viewport heights
Per-card keyframe tables (hand-authored — reproduce these numbers exactly)
Each card has a 4-keyframe yPercent track and a 4-keyframe rotation track, indexed [cardIndex][0] = yPercent keyframes, [cardIndex][1] = rotation keyframes:
const transforms = [
[ [10, 50, -10, 10], [ 20, -10, -45, 20] ], // card 0
[ [ 0, 47.5, -10, 15], [-25, 15, -45, 30] ], // card 1
[ [ 0, 52.5, -10, 5], [ 15, -5, -40, 60] ], // card 2
[ [ 0, 50, 30, -80], [ 20, -10, 60, 5] ], // card 3
[ [ 0, 55, -15, 30], [ 25, -15, 60, 95] ], // card 4
];
(Units: yPercent = % of card height 500px; rotation = degrees.)
The single pinned ScrollTrigger (no timeline, no scrub — all gsap.set inside onUpdate)
ScrollTrigger.create({
trigger: stickySection,
start: "top top",
end: `+=${stickyHeight}px`, // +=5*innerHeight
pin: true,
pinSpacing: true,
onUpdate: (self) => { /* see below */ },
});
- The
.stickysection pins attop topand stays pinned for 5× viewport height of scroll.pinSpacing:trueinserts the real spacer (which is why the800vhbody height lines up: hero 100vh + pinned-section spacer 600vh + outro 100vh ≈ 800vh). - No
scrub, no tween, no timeline. Every frameonUpdatereadsself.progress(0→1) and recomputes the header + all five cards with plaingsap.set. Motion is therefore 1:1 with scroll (Lenis supplies the smoothing).
Per-frame logic inside onUpdate(self)
1) The giant header pans left:
const progress = self.progress;
const maxTranslate = stickyHeader.offsetWidth - window.innerWidth; // 250vw − 100vw = 1.5 * viewport width
gsap.set(stickyHeader, { x: -progress * maxTranslate });
The header's x goes 0 → −1.5·innerWidth linearly across the whole pin — it slides left by one-and-a-half viewport widths, revealing the rest of the over-wide <h1>.
2) Each card, staggered, flies across with its own path:
cards.forEach((card, index) => {
const delay = index * 0.1125; // 0, 0.1125, 0.225, 0.3375, 0.45
const cardProgress = Math.max(0, Math.min((progress - delay) * 2, 1)); // 0→1 over 0.5 of scroll, after `delay`
if (cardProgress > 0) {
const cardStartX = 25;
const cardEndX = -650;
const yPos = transforms[index][0];
const rotations = transforms[index][1];
// X: single linear lerp start→end
const cardX = gsap.utils.interpolate(cardStartX, cardEndX, cardProgress); // xPercent 25 → -650
// Y & rotation: step through the 4-keyframe tracks (3 equal sub-segments)
const yProgress = cardProgress * 3; // 0 → 3
const yIndex = Math.min(Math.floor(yProgress), yPos.length - 2); // 0,1,2 (clamped to 2)
const yInterpolation = yProgress - yIndex; // fractional position within segment
const cardY = gsap.utils.interpolate(yPos[yIndex], yPos[yIndex + 1], yInterpolation);
const cardRotation = gsap.utils.interpolate(rotations[yIndex], rotations[yIndex + 1], yInterpolation);
gsap.set(card, { xPercent: cardX, yPercent: cardY, rotation: cardRotation, opacity: 1 });
} else {
gsap.set(card, { opacity: 0 });
}
});
Exact behavior to reproduce:
- Stagger via scroll delay:
delay = index * 0.1125. Card 0 starts moving immediately (progress 0), card 4 not until progress0.45. Each card's privatecardProgressruns0→1over 0.5 of the overall scroll (the* 2), so cards overlap heavily — a cascading swarm, not a single row. - Visibility gate: a card is
opacity:0until itscardProgress > 0; the instant it starts it snaps toopacity:1. So cards pop into existence off the right edge one after another, and never fade — they just appear/disappear by the gate. - Horizontal flight:
xPercent 25 → −650(linear). Since the card is CSS-anchored atleft:100%and is 325px wide,xPercent:25= ~81px right of the right edge (fully off-screen right), andxPercent:−650= ~2112px to the left (fully off-screen left). Each card therefore enters from off the right, crosses the entire viewport, and exits off the left. - Vertical bob + rotation: each is a piecewise-linear 4-keyframe track sampled by
yProgress = cardProgress*3.floor(yProgress)(clamped to max index 2) picks the segment,yProgress − yIndexis the eased-free lerp fraction inside it. So the card passes through its 4 authored yPercent values and its 4 authored rotation values in three equal thirds of its crossing. The tables above make each card bob up/down and swing through different rotations (e.g. card 3 whips down toyPercent −80and card 4 rotates all the way to+95°), giving every card a distinct arc. - All interpolation is linear (
gsap.utils.interpolate= plain lerp). There is noease,duration,delay(in the GSAP sense),stagger, SplitText, or CustomEase anywhere — the "stagger" is purely the per-index scrolldelayoffset, and the only smoothing is Lenis. Everything isgsap.set(instantaneous) recomputed each frame from scroll progress.
Net read: as you scroll into the pinned bone-colored section, the huge thin-serif title glides leftward while five black product cards launch from the right one-by-one, each tumbling and bobbing along its own hand-tuned path as it sails across and off the left side.
Assets / images
Five images total:
- 1 hero background — a full-bleed photographic backdrop for the
.herosection, sits above the pinned section. Landscape,cover-cropped. - 4 card images — one per card content; reuse the first card image again for the fifth card (so 4 unique files fill 5 cards, card 5 repeats card 1). Each is displayed in a
325px-wide card inside a200px-tall rounded frame withobject-fit:cover, so source them landscape ~3:2.
The card set reads as a clean, well-lit product-catalog / e-commerce series: a single hero object per frame, centered, shot on a solid pastel or seamless studio backdrop (soft lilac, buttery yellow, sage green, pale blue, etc.). Verified examples from the actual set, in order:
- A matte cream ceramic vase on a lilac backdrop.
- A polished chrome desk lamp on a warm yellow backdrop.
- A neat stack of hardcover books (muted orange/green/cream spines) on a sage-green backdrop.
- A tan-leather-and-tubular-steel lounge chair on a pale blue backdrop.
Any cohesive set of single-object studio product shots on pastel backdrops works. No brands or logos visible. Provide 4 card files (+1 hero); if fewer are available, repeat in order.
Behavior notes
- Desktop-first. Cards are a fixed
325×500px; header travel and card X usewindow.innerWidth, so the effect scales with viewport width. The only responsive rule adjusts nav alignment at≤900px. - No autoplay, no reduced-motion branch in the original — nothing moves until the user scrolls; the entire animation is scroll-scrubbed via the single
onUpdate, and scrolling back up reverses it exactly. overflow:hiddenon everysectionis required so the off-screen cards (well past both edges) and the 250vw header never spawn scrollbars.stickyHeightis captured once fromwindow.innerHeightat load (not recomputed on resize), matching the original.