All components

Sage East 3D Scroll

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

Open live demo ↗ Raw prompt (.md)

What it does

A fixed 3D perspective slider where ten alternating slides sit at staggered translateZ depths and fly toward the viewer as you scroll a 2000vh page. A per-slide ScrollTrigger (scrub) maps scroll progress to a Z increment, recomputes each slide's opacity via a mapRange fade, and cross-fades a blurred full-screen background image with gsap.to power3.out as each slide reaches the front.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scroll 3d-perspective translatez scrub depth fashion gsap editorial gallery

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

Sage East 3D Scroll

Goal

Build a fixed, full-viewport 3D perspective slider on a black stage. Ten editorial fashion cards sit at staggered translateZ depths inside a CSS perspective container and fly toward the camera as you scroll a very tall (2000vh) page — like flipping through a deck that keeps rushing out of deep space, alternating left and right. A per-slide ScrollTrigger (scrub) maps scroll progress to a shared Z increment, recomputes each card's opacity with a mapRange fade, and cross-fades a blurred, full-screen background image (gsap.to, power3.out) as each card reaches the front so the whole screen glows with the ambient color of the frontmost photo. There is no ScrollTrigger tween and no pinning — the scroll span itself is the timeline, and every transform is written by hand inside onUpdate.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger — nothing else (no Lenis, no SplitText, no CustomEase, no Three.js). Import as:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

All setup runs inside a single window.addEventListener("load", …) so getComputedStyle can read each slide's initial matrix3d.

Layout / HTML

Fixed nav and footer overlay a scroll-driven 3D stage. The .container is the tall scroll driver; everything visible is position: fixed inside it.

<nav>
  <div class="links-1"><a href="#">Works</a><a href="#">Archive</a></div>
  <div class="logo"><a href="#">Modavate</a></div>
  <div class="links-2"><a href="#">Info</a><a href="#">Contact</a></div>
</nav>

<footer>
  <p>Watch Showreel</p>
  <p>Launching 2024</p>
</footer>

<div class="container">
  <!-- blurred ambient background: the SAME 10 images, stacked & full-screen -->
  <div class="active-slide">
    <img src="/img/1.jpg" alt="" />
    … <img> ×10 in order …
  </div>

  <!-- the 3D deck: 10 flying cards -->
  <div class="slider">
    <div class="slide" id="slide-1">
      <div class="slide-copy">
        <p>Neo Elegance°</p>
        <p id="index">( ES 2023 0935 )</p>
      </div>
      <div class="slide-img"><img src="/img/1.jpg" alt="" /></div>
    </div>
    … slide-2 … slide-10 (each with its own copy + image) …
  </div>
</div>
<script type="module" src="./script.js"></script>

Ten slides, ids slide-1slide-10. Each .slide holds a .slide-copy (a title <p> plus a <p id="index"> catalog code) and a .slide-img > img. Sample titles (fictional): Neo Elegance°, Future Luxe, Cyber Glam, Visionary Threads, Galactic Chic, Tech Sophistication, Avant Edge, Moda Futura, Eco Futurist, Sleek Tomorrow. Index codes read like ( ES 2023 0935 ), 0936, … 0944. Every card also has a twin image in .active-slide, in the same order, which becomes its blurred background.

Styling

  • Palette: --color-accent: rgb(230, 170, 40) (warm amber/gold) for all text; page background: #000.
  • Global reset * { margin:0; padding:0; box-sizing:border-box }; img { width:100%; height:100%; object-fit:cover }.
  • Type: all a/p are text-transform:uppercase; font-size:12px; color:var(--color-accent), font family a neutral grotesque (original uses "Basis Grotesque Pro" — substitute any clean sans, e.g. system-ui/Helvetica). The .logo a and slide titles use an extended display font (original "PP Monument Extended" — substitute any wide/extended weight, or a bold condensed-inverse feel): logo 16px, font-weight:bolder, letter-spacing:-0.02em; slide title 13px, bolder, line-height:150%, centered. p#index uses the grotesque at 11px, font-weight:400, margin-bottom:0.75em.
  • nav: position:fixed; top:0; width:100%; padding:1.5em 2em; display:flex; align-items:center. Its three > div children each flex:1; .links-1/.links-2 are display:flex; gap:2em (links-2 justified flex-end); .logo centers its link.
  • footer: position:fixed; bottom:0; width:100%; padding:1.5em 2em; display:flex; justify-content:space-between; align-items:center.
  • .container: width:100%; height:2000vh — the scroll length that drives the whole effect.
  • .active-slide (ambient backdrop): position:fixed; inset:0; width:100%; height:100%; overflow:hidden; background:#000; opacity:0.35; z-index:-1. Its imgs are position:absolute; filter:blur(50px); transform:scale(1.125) — stacked full-screen; the frontmost non-faded one shows through as a soft, out-of-focus color wash.
  • .slider (the 3D camera): position:fixed; top:0; width:100vw; height:100vh; overflow:hidden; transform-style:preserve-3d; perspective:750px. This perspective:750px is the lens — it makes the deep negative-Z slides read as tiny far-away specks that balloon to full size as they approach z:0.
  • .slide: position:absolute; width:400px; height:500px; overflow:hidden (a 4:5 card).
Initial slide placement (critical — the JS reads these back)

Each #slide-N is absolutely positioned at top:50%, with transform:translateX(-50%) translateY(-50%) translateZ(<Zn>px). Odd slides sit at left:70%, even slides at left:30% — so as they rush forward they alternate right / left of center. The Z depths step by +2500px, deepest first:

| slide | left | translateZ | opacity | |------|------|-----------|---------| | 1 | 70% | −22500px | 0 | | 2 | 30% | −20000px | 0 | | 3 | 70% | −17500px | 0 | | 4 | 30% | −15000px | 0 | | 5 | 70% | −12500px | 0 | | 6 | 30% | −10000px | 0 | | 7 | 70% | −7500px | 0 | | 8 | 30% | −5000px | 0 | | 9 | 70% | −2500px | 0.5 | | 10 | 30% | 0px | 1 |

Only slides 9 (opacity:0.5) and 10 (opacity:1) are visible at rest; everything else is fully transparent far away. Slide 10 is the hero at the camera plane; slide 9 is the half-faded one just behind it.

GSAP effect (the important part — be exhaustive)

Two helpers
// read the CURRENT translateZ out of the computed matrix3d (m43 = 15th value, index 14)
function getInitialTranslateZ(slide) {
  const style = window.getComputedStyle(slide);
  const matrix = style.transform.match(/matrix3d\((.+)\)/);
  if (matrix) {
    const values = matrix[1].split(", ");
    return parseFloat(values[14]) || 0;   // the z translation, e.g. -22500 … 0
  }
  return 0;
}

// plain linear remap
function mapRange(value, inMin, inMax, outMin, outMax) {
  return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
One ScrollTrigger per slide
const slides = gsap.utils.toArray(".slide");
const activeSlideImages = gsap.utils.toArray(".active-slide img");

slides.forEach((slide, index) => {
  const initialZ = getInitialTranslateZ(slide);   // -22500 for slide1 … 0 for slide10

  ScrollTrigger.create({
    trigger: ".container",
    start: "top top",
    end: "bottom bottom",
    scrub: true,
    onUpdate: (self) => {
      const progress = self.progress;             // 0 → 1 across the full 2000vh
      const zIncrement = progress * 22500;         // every slide travels +22500px in Z
      const currentZ = initialZ + zIncrement;

      // opacity from depth, in two linear bands
      let opacity;
      if (currentZ >= -2500) {
        opacity = mapRange(currentZ, -2500, 0, 0.5, 1);   // near front: 0.5 → 1
      } else {
        opacity = mapRange(currentZ, -5000, -2500, 0, 0.5); // approaching: 0 → 0.5
      }
      slide.style.opacity = opacity;

      // write the live depth
      slide.style.transform =
        `translateX(-50%) translateY(-50%) translateZ(${currentZ}px)`;

      // cross-fade THIS slide's blurred background twin
      if (currentZ < 100) {
        gsap.to(activeSlideImages[index], 1.5, { opacity: 1, ease: "power3.out" });
      } else {
        gsap.to(activeSlideImages[index], 1.5, { opacity: 0, ease: "power3.out" });
      }
    },
  });
});
Exactly how it reads on screen
  • Trigger / span: trigger:".container", start:"top top", end:"bottom bottom", scrub:true. There is no tween attached and no pinonUpdate is the whole animation. self.progress runs 0→1 over the 1900vh of real scroll, and scrub:true keeps progress glued to the scrollbar so the deck tracks the wheel directly (no lag, no autoplay).
  • Shared Z increment: every slide adds the *same* progress * 22500 to its own initialZ. Because the ten initialZ values are spaced 2500px apart, the cards arrive at the camera one after another, evenly staggered — a rolling procession, not a single move. At progress:1 slide-1 lands exactly at z:0 (its start −22500 + 22500) while slide-10 has flown to z:22500 (blown past the lens and clipped away).
  • Opacity bands (mapRange): a slide is invisible until it's within 5000px of the front. From z:−5000 → −2500 opacity ramps 0 → 0.5; from z:−2500 → 0 it ramps 0.5 → 1; at/after z:0 mapRange(currentZ,-2500,0,0.5,1) keeps returning ≥1 so it stays full-opacity as it rushes through and past the viewer. This is why slide-9 starts at 0.5 and slide-10 at 1 — the formula evaluated at their resting Z.
  • Ambient background cross-fade: each slide owns the same-index image inside .active-slide. While a slide is at or in front of the lens (currentZ < 100) its blurred twin fades in to opacity:1; once it passes (currentZ ≥ 100) the twin fades out to 0 — both over 1.5s with power3.out. The .active-slide images are stacked full-screen, blur(50px) scale(1.125), inside a container at opacity:0.35, so what you actually see is a soft, defocused wash of the frontmost card's colors bleeding across the whole black screen, hand-off blending from one photo to the next as the deck advances.
  • Transform authored by hand: slide.style.transform and slide.style.opacity are set imperatively every onUpdate; GSAP is only used for the 1.5s background tweens. No timeline, no labels, no stagger config — the *stagger is baked into the initial Z offsets*, and the *easing of the flight is linear* (direct scrub), with the only eased tween being the power3.out background fade.

Assets / images

10 editorial fashion photographs, portrait orientation ~2:3 (each shown twice: once as a crisp 400×500 4:5 card via object-fit:cover, once as a full-screen blur(50px) background twin). Keep them a cohesive but varied high-fashion set with a warm, saturated, editorial mood, e.g.: tight profile-portrait headshots on flat saturated backdrops (violet, cobalt blue); full-length couture looks on a solid color seamless (a deep-red gown/coat against electric blue); motion / twirl garment shots in a neutral studio (a beige wool coat caught mid-spin); backlit, soft-focus portraits among blossoms (hazy pastel bloom); and flowing silk-fabric abstractions on pastel grounds (amber/orange satin ribboning across teal). Provide 10 files named 1.jpg … 10.jpg; if fewer are available, repeat in order. No real brand names — use the fictional "Modavate" wordmark and the invented collection titles above.

Behavior notes

  • Scroll-only, wheel/trackpad/scrollbar driven — no click, hover, or keyboard; nothing autoplays. At scroll top you see slide-10 sharp and slide-9 half-faded, deep-space behind.
  • Desktop-tuned: card size and Z depths are fixed px (400×500, up to −22500), and perspective:750px is calibrated for a large viewport; there are no responsive breakpoints in the original.
  • The 2000vh page height gives a long, slow reveal — roughly one slide surfacing per ~200vh of scroll.
  • No reduced-motion branch in the original; motion is entirely user-driven, but the background fades keep their 1.5s power3.out glide after the scroll input.