All components

3D Tunnel Image Slider Carousel

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen animated monochrome tunnel, rendered as a Three.js fragment-shader plane, sits behind ten framed cinematic film stills laid out along the Z axis inside a CSS perspective container. Smooth scrolling via Lenis feeds a GSAP ScrollTrigger with scrub:1 whose onUpdate handlers push each slide's translateZ toward the camera and fade it in through a Z-to-opacity mapping, while the same scroll progress drives the shader's scrollOffset uniform to warp the tunnel in sync. Presented as the showreel of a film and color studio, 'Tunnel Vision'.

How it's built

Category3d-webgl
Techgsap, lenis, three
GSAP pluginsScrollTrigger
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

3d tunnel webgl shader three.js scrolltrigger lenis carousel film cinematic portfolio

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

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 plugin ScrollTrigger (import { ScrollTrigger } from "gsap/ScrollTrigger", register with gsap.registerPlugin(ScrollTrigger)).
  • lenis (npm) for smooth scroll, wired into GSAP's ticker.
  • three (npm) imported import * 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>
  • .slider is empty in the HTML — the JS generates the 10 .slide elements 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 and appendChild-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:exclusion is 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 ScrollTrigger trigger. 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:500px is 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:

  1. Long-exposure figure raising one hand behind fine vertical slats, red & cyan light bands on near-black.
  2. Portrait through red ribbed lenticular glass, teal-split face, glowing red LED bar across the eyes.
  3. Two models (leather jacket + reflective goggles / blonde wig) on turquoise, orange vertical light stripes across their faces.
  4. Portrait behind red-and-white ribbed glass, blue-lit face, horizontal white streak on saturated red.
  5. Dark long-exposure crouching figure behind vertical slats, red and white-blue horizontal streaks.
  6. Close-up cyberpunk portrait, teal-lit man on vivid red, glowing orange holographic HUD visor.
  7. Blurred portrait behind slats, yellow LED eye-bar, magenta/white streaks on dark navy.
  8. Two models in black on deep red, both in reflective/LED visor sunglasses, one sharp / one blurred.
  9. Side-profile portrait behind fine lines, yellow-green LED bar, red-white streaks on dark teal.
  10. 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 scrollOffset warp 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 .container at height: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:500px on .slider + transform-style:preserve-3d are mandatory; without them the Z translations do nothing.