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-1 … slide-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; pagebackground: #000. - Global reset
* { margin:0; padding:0; box-sizing:border-box };img { width:100%; height:100%; object-fit:cover }. - Type: all
a/paretext-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 aand slide titles use an extended display font (original "PP Monument Extended" — substitute any wide/extended weight, or a bold condensed-inverse feel): logo16px,font-weight:bolder,letter-spacing:-0.02em; slide title13px,bolder,line-height:150%, centered.p#indexuses the grotesque at11px,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> divchildren eachflex:1;.links-1/.links-2aredisplay:flex; gap:2em(links-2 justified flex-end);.logocenters 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. Itsimgs areposition: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. Thisperspective:750pxis the lens — it makes the deep negative-Z slides read as tiny far-away specks that balloon to full size as they approachz: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 pin —onUpdateis the whole animation.self.progressruns 0→1 over the 1900vh of real scroll, andscrub:truekeepsprogressglued to the scrollbar so the deck tracks the wheel directly (no lag, no autoplay). - Shared Z increment: every slide adds the *same*
progress * 22500to its owninitialZ. Because the teninitialZvalues are spaced 2500px apart, the cards arrive at the camera one after another, evenly staggered — a rolling procession, not a single move. Atprogress:1slide-1 lands exactly atz:0(its start −22500 + 22500) while slide-10 has flown toz:22500(blown past the lens and clipped away). - Opacity bands (
mapRange): a slide is invisible until it's within 5000px of the front. Fromz:−5000 → −2500opacity ramps0 → 0.5; fromz:−2500 → 0it ramps0.5 → 1; at/afterz:0mapRange(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 toopacity:1; once it passes (currentZ ≥ 100) the twin fades out to0— both over 1.5s withpower3.out. The.active-slideimages are stacked full-screen,blur(50px) scale(1.125), inside a container atopacity: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.transformandslide.style.opacityare set imperatively everyonUpdate; 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 thepower3.outbackground 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:750pxis 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.outglide after the scroll input.