All components

CriticalDanger Scroll Cards

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-pinned section (5 viewport heights, Lenis-synced) where a giant heading translates horizontally as you scroll while five product cards fly in from the right, each staggered by a scroll delay and animated across the screen with per-card X/Y offsets and rotation via ScrollTrigger's onUpdate progress and gsap.utils.interpolate.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll pin horizontal-scroll cards scrolltrigger lenis interpolate editorial

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-content holding .card-title > h2 and .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 tall 800vh body 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:hidden clips 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 from section = 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, 30vw tall.

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 at top: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 .sticky section pins at top top and stays pinned for 5× viewport height of scroll. pinSpacing:true inserts the real spacer (which is why the 800vh body height lines up: hero 100vh + pinned-section spacer 600vh + outro 100vh ≈ 800vh).
  • No scrub, no tween, no timeline. Every frame onUpdate reads self.progress (0→1) and recomputes the header + all five cards with plain gsap.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 progress 0.45. Each card's private cardProgress runs 0→1 over 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:0 until its cardProgress > 0; the instant it starts it snaps to opacity: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 at left:100% and is 325px wide, xPercent:25 = ~81px right of the right edge (fully off-screen right), and xPercent:−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 − yIndex is 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 to yPercent −80 and 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 no ease, duration, delay (in the GSAP sense), stagger, SplitText, or CustomEase anywhere — the "stagger" is purely the per-index scroll delay offset, and the only smoothing is Lenis. Everything is gsap.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 .hero section, 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 a 200px-tall rounded frame with object-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:

  1. A matte cream ceramic vase on a lilac backdrop.
  2. A polished chrome desk lamp on a warm yellow backdrop.
  3. A neat stack of hardcover books (muted orange/green/cream spines) on a sage-green backdrop.
  4. 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 use window.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:hidden on every section is required so the off-screen cards (well past both edges) and the 250vw header never spawn scrollbars.
  • stickyHeight is captured once from window.innerHeight at load (not recomputed on resize), matching the original.