3D Tunnel Image Slider — WebGL Shader Tunnel + Scroll-Driven Z-Flight Carousel
Goal
Build a full-screen experience where ten framed portrait images fly toward the camera down the Z axis of a CSS-perspective tunnel, over a hypnotic monochrome neon-tunnel pattern rendered by a Three.js fragment shader that fills the whole background. Smooth scrolling (Lenis) feeds a GSAP ScrollTrigger with scrub: 1: the same scroll progress (a) pushes every slide's translateZ from deep-space toward the viewer, fading each one in as it nears, and (b) drives a scrollOffset uniform that warps/advances the shader tunnel in sync. Result: as you scroll, images emerge one after another out of a spinning light-tunnel and rush past you, with the pattern accelerating in lock-step.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by Vite (npm project).
gsap(npm) plus the GSAP pluginScrollTrigger(import { ScrollTrigger } from "gsap/ScrollTrigger", register withgsap.registerPlugin(ScrollTrigger)).lenis(npm) for smooth scroll, wired into GSAP's ticker.three(npm) importedimport * as THREE from "three"— a single full-screen shader plane (raw GLSL,ShaderMaterial). No other Three geometry.- The tunnel background is WebGL; the carousel slides are plain DOM
<div>s transformed in 3D by CSS + inline styles. The two systems only share the scroll progress value.
Lenis + GSAP ticker wiring (put at top of the module, runs immediately):
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Layout / HTML
Class names / ids are load-bearing (JS queries .slider, .container; CSS styles the rest). Nav/footer copy is neutral fictional demo text (no real brands).
<nav>
<div class="nav-items">
<a href="#">Works</a>
<a href="#">Archive</a>
</div>
<div class="logo">
<a href="#">Tunnel Vision</a>
</div>
<div class="nav-items">
<a href="#">Info</a>
<a href="#">Contact</a>
</div>
</nav>
<footer>
<p>Watch Showreel</p>
<p>Launching 2024</p>
</footer>
<div class="container">
<div class="overlay"></div>
<div class="slider"></div>
</div>
<!-- Raw GLSL shaders live as inline <script> tags read by their id -->
<script id="vertexShader" type="x-shader/x-vertex"> …see shaders… </script>
<script id="fragmentShader" type="x-shader/x-fragment"> …see shaders… </script>
<script type="module" src="./script.js"></script>
.slideris empty in the HTML — the JS generates the 10.slideelements into it at load.- Each generated slide has this shape:
``html <div class="slide" id="slide-N"> <div class="slide-img"><img src="/…/imgN.jpg" alt=""></div> <div class="slide-copy"><p>{title}</p><p>{id}</p></div> </div> ``
- The Three.js
renderer.domElement<canvas>is created in JS andappendChild-ed to<body>; CSS pins it behind everything.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. body { width:100%; height:100%; background:#000; overflow-x:hidden; }.
Fonts (both proprietary in the original — use close generic fallbacks):
- Body / links / captions: a monospace face (
"Akkurat Mono", Arial, sans-serif) — any clean mono works.a, p { color:#fff; text-decoration:none; text-transform:uppercase; font-family:<mono>; font-size:12px; font-weight:500; }. - Logo: an elegant condensed display serif (
"Timmons NY 2.005", Arial, sans-serif) — a tall light-weight serif..logo a { font-family:<display-serif>; font-size:48px; font-weight:lighter; }.
Key rules:
canvas { position:fixed; top:0; left:0; z-index:-1; }— the shader tunnel sits behind the whole page.img { width:100%; height:100%; object-fit:cover; }.nav, footer { position:fixed; width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; mix-blend-mode:exclusion; z-index:2; }—mix-blend-mode:exclusionis what makes the nav/footer text invert against whatever tunnel/slide is behind it.nav { top:0; } footer { bottom:0; }.nav > div { flex:1; display:flex; gap:2em; };.logo { display:flex; justify-content:center; };.nav-items:nth-child(3) { justify-content:flex-end; }(left group / centered logo / right group)..container { width:100%; height:2000vh; }— this 20-viewport-tall block is the entire scroll runway and the only ScrollTriggertrigger. Nothing visible scrolls; the height just gives scroll distance..slider { position:fixed; top:0; width:100vw; height:100vh; transform-style:preserve-3d; perspective:500px; overflow:hidden; z-index:2; }— the fixed 3D stage.perspective:500pxis critical: with the huge Z translations below, it makes slides shrink to nothing when far and balloon as they reach the camera..slide { position:absolute; width:400px; height:500px; will-change:transform, opacity; }(each frame is 4:5 portrait)..slide-img { width:100%; height:100%; padding:0.5em; background-color:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); backdrop-filter:blur(20px); }— a frosted glass mat around each photo..slide-copy { position:absolute; width:100%; bottom:-24px; display:flex; justify-content:space-between; }— title left, id right, just below the frame..overlay { position:fixed; top:0; left:0; width:100%; height:100%; background: radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%); }— a radial vignette: clear center, fully black corners. It sits above the tunnel canvas but the slider (z-index:2) renders above it.- Include the standard Lenis helper CSS (
.lenis.lenis-smooth { scroll-behavior:auto !important; },.lenis.lenis-stopped { overflow:hidden; }, etc.).
The star effect (be exact)
Part A — Three.js shader tunnel (full-screen background)
Orthographic full-screen plane textured by a raw fragment shader. Set up once:
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const geometry = new THREE.PlaneGeometry(2, 2); // covers the whole clip space
const uniforms = {
iTime: { value: 0 },
iResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
scrollOffset: { value: 0 },
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: document.getElementById("vertexShader").textContent,
fragmentShader: document.getElementById("fragmentShader").textContent,
});
scene.add(new THREE.Mesh(geometry, material));
Vertex shader (pass-through, no projection — the plane already spans clip space):
void main() {
gl_Position = vec4(position, 1.0);
}
Fragment shader — the tunnel. A monochrome (white-on-black) radial pattern: atan of the centered pixel makes angular spokes, and sin of a 1/length (reciprocal-radius) term makes concentric rings that pack tighter toward the center, reading as depth. iTime spins/pulses it continuously; scrollOffset (0→1 from ScrollTrigger) is added with a ×200 gain so scrolling rushes the tunnel forward:
uniform vec2 iResolution;
uniform float iTime;
uniform float scrollOffset;
void mainImage(out vec4 o, vec2 I) {
I -= o.zw = iResolution.xy / 2.0; // recenter pixel; stash half-res in o.zw
float t = iTime * 5.0 + scrollOffset * 200.0; // time advance + scroll rush
float pattern = sin(atan(I.y, I.x) / 0.1) // angular spokes (angle × 10)
* sin(20.0 * (o.w /= length(I)) + t)// rings from reciprocal radius, animated
- 1.0 + o.w; // brighten toward center
float monochrome = 1.0 - pattern * 0.5;
float invertedMonochrome = 1.0 - monochrome; // = pattern * 0.5
o = vec4(invertedMonochrome, invertedMonochrome, invertedMonochrome, 1.0);
}
void main() { mainImage(gl_FragColor, gl_FragCoord.xy); }
Continuous render loop (drives iTime from wall-clock delta, independent of scroll — the tunnel is always alive):
let lastTime = 0;
function animateTunnel(time) {
const deltaTime = time - lastTime;
lastTime = time;
uniforms.iTime.value += deltaTime * 0.001; // ms → sec-ish
renderer.render(scene, camera);
requestAnimationFrame(animateTunnel);
}
animateTunnel(0);
Resize: renderer.setSize(innerWidth, innerHeight); uniforms.iResolution.value.set(innerWidth, innerHeight);.
Part B — generate the 10 slides
Constants: totalSlides = 10, zStep = 2500, initialZ = -22500. Loop i = 1..10, build the slide DOM (img.src = "/…/img" + i + ".jpg", caption <p>{title}</p><p>{id}</p>), append to .slider, then set its starting transform:
const zPosition = initialZ + (i - 1) * zStep; // -22500, -20000, … , 0 (slide 10 starts at 0)
const xPosition = i % 2 === 0 ? "30%" : "70%"; // alternate left / right → tunnel weave
const opacity = i === totalSlides ? 1 : 0; // only the last slide (z=0) starts visible
gsap.set(slide, {
top: "50%", left: xPosition,
xPercent: -50, yPercent: -50,
z: zPosition, opacity,
});
So the ten frames are stacked receding into the distance every 2500px, alternating horizontally between 30% and 70% of the width so they zig-zag rather than stack dead-center. The nearest (slide 10) sits at the camera plane; the rest are far away and invisible.
Part C — scroll wiring (scrub: 1, one shader trigger + one per slide)
All triggers use the same tall runway: trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1.
Shader trigger — map scroll progress straight into the uniform:
ScrollTrigger.create({
trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1,
onUpdate: (self) => { uniforms.scrollOffset.value = self.progress; },
});
Per-slide triggers — for each slide, read its starting Z with gsap.getProperty(slide, "z"), then on every update push it +22500 across the full scroll and recompute opacity from its Z:
const mapRange = (v, inMin, inMax, outMin, outMax) =>
((v - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
slides.forEach((slide) => {
const initialZ = gsap.getProperty(slide, "z");
ScrollTrigger.create({
trigger: ".container", start: "top top", end: "bottom bottom", scrub: 1,
onUpdate: (self) => {
const currentZ = initialZ + self.progress * 22500; // travels 22500px toward camera
let opacity;
if (currentZ >= -2500) opacity = mapRange(currentZ, -2500, 0, 0, 1); // fade in over last 2500px
else opacity = 0; // hidden while far
slide.style.opacity = opacity;
slide.style.transform =
`translateX(-50%) translateY(-50%) translateZ(${currentZ}px)`;
},
});
});
How it reads: each slide's Z sweeps by exactly +22500 over the scroll. A slide is fully hidden while currentZ < -2500 (tiny and far), fades 0→1 as its Z crosses −2500 → 0 (mapRange, un-clamped so it stays ≥1 = fully opaque afterward), and — because the transform keeps applying past 0 into positive Z — it keeps rushing toward and past the camera, growing huge through the perspective:500px before exiting. Slides enter staggered (their start Z is offset by 2500 each), so scrolling produces a continuous stream of frames emerging from the tunnel one after another. Note the per-frame slide.style.transform uses a fixed translateX(-50%) translateY(-50%) to re-center on its left anchor (30%/70%), so the horizontal zig-zag from Part B persists while only Z changes.
Because everything is scrub: 1, scrolling up reverses the whole thing — slides retreat back down the tunnel and the shader unwinds — with a ~1s smoothing lag.
Assets / images
10 carousel slides, each a portrait 4:5 frame (displayed 400×500, object-fit:cover). A cohesive cyberpunk / editorial-fashion set on saturated grounds — moody portraits behind ribbed/lenticular glass, figures crossed by neon light bars and long-exposure streaks, models in reflective visors — heavy on red, teal/cyan, and orange neon against near-black or vivid-red backdrops. Describe generically, no brands/logos. Any 10 images work; if you have fewer, repeat in order — the effect is identical. Example roles:
- Long-exposure figure raising one hand behind fine vertical slats, red & cyan light bands on near-black.
- Portrait through red ribbed lenticular glass, teal-split face, glowing red LED bar across the eyes.
- Two models (leather jacket + reflective goggles / blonde wig) on turquoise, orange vertical light stripes across their faces.
- Portrait behind red-and-white ribbed glass, blue-lit face, horizontal white streak on saturated red.
- Dark long-exposure crouching figure behind vertical slats, red and white-blue horizontal streaks.
- Close-up cyberpunk portrait, teal-lit man on vivid red, glowing orange holographic HUD visor.
- Blurred portrait behind slats, yellow LED eye-bar, magenta/white streaks on dark navy.
- Two models in black on deep red, both in reflective/LED visor sunglasses, one sharp / one blurred.
- Side-profile portrait behind fine lines, yellow-green LED bar, red-white streaks on dark teal.
- Portrait through red ribbed glass, two bright teal glowing orbs over the eyes on vivid red.
Captions — each slide shows a one-word title (left) and a neutral catalog id (right). Use neutral values, e.g. titles Neon, Volt, Echo, Glitch, Pulse, Cipher, Nova, Synth, Flux, Vapor and short numeric ids like 30128, 39102, 84729, … (no brand-prefixed codes).
Behavior notes
- Two decoupled clocks: the shader animates continuously via its own rAF loop (never idle); the slide Z-flight and the shader's
scrollOffsetwarp are purely scroll-driven. Idle = tunnel keeps spinning, slides frozen. - Scrub smoothing (
scrub: 1) gives ~1s inertia to both the slide motion and the tunnel-rush; fully reversible on scroll-up. - Keep
.containeratheight:2000vh— that height *is* the animation timeline; shrink it and slides fly past too fast. - Desktop / performance-heavy: WebGL shader +
backdrop-filter:blur(20px)on ten frames. No reduced-motion branch and no mobile layout in the original — treat as desktop-oriented, not mobile-safe. perspective:500pxon.slider+transform-style:preserve-3dare mandatory; without them the Z translations do nothing.