All components

3D Scroll Tunnel

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite 3D image tunnel driven by the mouse wheel: six layers of four images each sit on an ellipse inside a perspective container, and a gsap.ticker loop lerps a virtual scroll value to push layers along the Z axis, wrapping depth modulo the tunnel length for endless looping. A per-item black overlay driven by a --overlay CSS variable fades images in from the depths and darkens them out as they pass the camera.

How it's built

Category3d-webgl
Techgsap
Complexitypage
Performance costlight
Mobile-safedesktop-first

3d tunnel wheel infinite-loop css-3d perspective lerp 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

3D Scroll Tunnel

Goal

Build a full-viewport, pitch-black "spotlight" stage containing an infinite 3D image tunnel flown by the mouse wheel. A ring of four images sits on an ellipse; several of these rings are stacked in depth along the Z axis inside a CSS perspective container. A gsap.ticker loop lerps a virtual scroll value and pushes every ring toward the camera, wrapping depth modulo the tunnel length so the flight never ends. Each image carries a black overlay whose opacity is driven by a --overlay CSS variable: images emerge from black in the far depths, resolve to full clarity at the camera plane, then darken back to black and vanish as they pass the lens. There is no native page scroll and no ScrollTrigger — everything is a wheel accumulator plus a per-frame ticker.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no plugins, no Lenis, no ScrollTrigger. Import as:

import gsap from "gsap";

The whole animation runs inside a single gsap.ticker.add(() => { ... }) callback. The wheel drives a target value; the ticker lerps a current value toward it and applies 3D transforms with gsap.set.

Layout / HTML

The static HTML is essentially empty — the tunnel is built entirely in JS:

<body>
  <section class="spotlight"></section>
  <script type="module" src="./script.js"></script>
</body>

JS creates and appends:

section.spotlight            (the fixed black stage, perspective container)
  div.tunnel                 (centered 3D group, transform-style: preserve-3d)
    div.layer  × N           (one per depth ring; its translateZ is animated)
      div.item × up to 4     (positioned around an ellipse; 180×220 px each)
        img                  (the tunnel image)
        div.item-overlay     (black veil, opacity = var(--overlay))

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }. Images: img { width:100%; height:100%; object-fit:cover; }.

  • .spotlight: position:fixed; width:100%; height:100svh; background-color:#000; perspective:1000px; overflow:hidden; — this perspective:1000px is the camera and is essential to the 3D depth.
  • .tunnel: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); transform-style:preserve-3d; — centered in the stage; preserve-3d lets children live in real 3D space so their translateZ reads as depth.
  • .layer: position:absolute; (no width/height — a pure transform group). Sits at the tunnel center; JS animates its z (translateZ).
  • .item: position:absolute; width:180px; height:220px; — placed via inline left/top.
  • .item-overlay: position:absolute; inset:0; background:#000; opacity:var(--overlay, 1); pointer-events:none; — a black rectangle over each image whose opacity is fed by the inherited --overlay variable.

No custom fonts, no text — this is a pure image/motion piece on a black background.

Building the tunnel (JS construction — do this before the ticker)

Config constants
const CONFIG = { totalImages: 12, scrollSpeed: 2, layerGap: 2500, lerp: 0.07 };
Derived counts (compute exactly like this)
const contentLayerCount = Math.ceil(CONFIG.totalImages / 4); // 3 unique rings of 4 images
const totalLayerCount   = Math.max(contentLayerCount, 6);    // 6 rings actually rendered (min 6)
const tunnelDepth       = totalLayerCount * CONFIG.layerGap;  // 15000  (the modulo wrap length)
const visibleDepth      = 3 * CONFIG.layerGap;               // 7500   (fade-in distance)
const exitPoint         = 1500;                              // where a ring "exits" past the lens
const initialScroll     = 750;                               // starting virtual-scroll offset

With 12 images and 4 per ring there are only 3 unique rings of imagery, but 6 layers are rendered; the image sets repeat (rings 0,1,2,then 0,1,2 again) so the tunnel is always populated.

Layer + item generation loop

tunnelEl is appended to .spotlight. Then for i from 0 to totalLayerCount-1:

  • Create a .layer div.
  • imageStartIndex = (i % contentLayerCount) * 4 → i:0→0, 1→4, 2→8, 3→0, 4→4, 5→8.
  • For j from 0 to 3:
  • imageNumber = imageStartIndex + j + 1; if imageNumber > CONFIG.totalImages break.
  • Place the item around an ellipse: angle = (j/4) * Math.PI*2 - Math.PI/2 (starts at top, goes clockwise: top → right → bottom → left).
  • radiusX = 400, radiusY = 280.
  • itemX = Math.cos(angle) * radiusX - 90 (the -90 = half the 180px width, so the item is centered on the ellipse point).
  • itemY = Math.sin(angle) * radiusY - 110 (the -110 = half the 220px height).
  • The four resulting positions are: top (-90, -390), right (310, -110), bottom (-90, 170), left (-490, -110).
  • Build .item with inline left:${itemX}px; top:${itemY}px;, append an <img> (src = tunnel image imageNumber) and a .item-overlay div. Append item → layer.
  • Append layer → tunnel. Push { el: layerEl, baseZ: -i * CONFIG.layerGap } into a layerData array. Ring 0 sits at z=0, ring 1 at −2500, … ring 5 at −12500 (marching away from the camera).

GSAP effect (the important part — be exhaustive)

1. Virtual scroll accumulator (wheel-driven)
let targetScroll  = initialScroll; // 750
let currentScroll = initialScroll; // 750
window.addEventListener("wheel", (e) => {
  targetScroll += e.deltaY * CONFIG.scrollSpeed; // scrollSpeed = 2
});

The page itself never scrolls (the stage is position:fixed, full viewport). Wheel deltas simply accumulate into targetScroll. Scrolling down (positive deltaY) increases the value and flies forward through the tunnel; scrolling up flies backward. There is no clamping — the value can grow unbounded, and the modulo wrap keeps depth in range.

2. Depth-driven overlay function
function calculateOverlay(z) {
  if (z > exitPoint)     return 1;                 // z > 1500: past the lens → fully black
  if (z > 0)             return z / exitPoint;      // 0 < z < 1500: darkening as it exits (0 → 1)
  if (z > -visibleDepth) {                          // -7500 < z < 0: fading IN from the depths
    const progress = Math.abs(z) / visibleDepth;    // 1 (far) → 0 (at camera plane)
    return progress * progress;                     // QUADRATIC ease-in of the black veil
  }
  return 1;                                          // z <= -7500: too far → fully black
}

Interpretation of the veil opacity across depth:

  • Far field (z ≤ −7500): overlay = 1, image is solid black.
  • Approach (−7500 → 0): overlay eases from 1 down to 0 following (|z|/7500)², so the image swells out of darkness and only becomes fully clear right at the camera plane (z = 0).
  • Fly-through (0 → 1500): overlay ramps linearly 0 → 1, so the image darkens again as it blows past the lens.
  • Behind camera (z > 1500): overlay = 1, black.

The visible "clear" band is therefore a thin slice around z = 0; only a couple of rings are legible at once, everything else fades to black — this is what sells the tunnel depth.

3. The ticker loop (runs every frame)
gsap.ticker.add(() => {
  // (a) lerp the virtual scroll toward the wheel target
  currentScroll += (targetScroll - currentScroll) * CONFIG.lerp; // lerp = 0.07 → heavy inertia/glide

  // (b) reposition every ring in depth
  layerData.forEach((layer) => {
    let z = layer.baseZ + currentScroll;                  // ring's raw depth
    z = ((z % tunnelDepth) + tunnelDepth) % tunnelDepth;  // wrap into [0, 15000) (handles negatives)
    z = z - tunnelDepth + exitPoint;                      // remap to [-13500, 1500): far behind → just past lens

    const overlay = calculateOverlay(z);

    gsap.set(layer.el, {
      z: z,                                               // translateZ in px (the 3D depth)
      "--overlay": Math.min(1, Math.max(0, overlay)),     // clamp 0..1, fed to .item-overlay
      visibility: overlay >= 1 ? "hidden" : "visible",    // fully-black rings are hidden (perf + no z-fighting)
    });
  });
});

Key mechanics to reproduce exactly:

  • Lerp factor 0.07 gives a long, floaty glide: after you stop scrolling, the tunnel keeps drifting and eases to rest. Do not replace with a direct assignment.
  • The three-step z math is the heart of the infinite loop. ((z % tunnelDepth) + tunnelDepth) % tunnelDepth is a positive modulo (JS % can go negative), wrapping any depth into [0, 15000). Subtracting tunnelDepth and adding exitPoint re-centers that range to [−13500, 1500), so as a ring's z crosses 1500 it seamlessly re-enters at −13500 (deep in the fog) — a perfectly recycled, endless tunnel with no visible seam because those wrap points are always at overlay = 1 (black).
  • gsap.set (not .to) — the tween is the ticker itself; each frame writes an absolute state. z maps to translateZ. The --overlay custom property is set on the .layer, and each child .item-overlay inherits it via opacity:var(--overlay).
  • visibility:hidden when overlay ≥ 1 removes fully-black rings from paint.

No SplitText, no CustomEase, no ScrollTrigger, no timeline — the only easing is the manual lerp = 0.07 interpolation of currentScroll, plus the quadratic overlay curve.

Assets / images

12 tunnel images, each displayed in a fixed 180×220 px portrait frame with object-fit:cover (the tunnel frame is ~9:11 / ~4:5 portrait; the source files themselves vary from square-ish to tall portrait and are center-cropped to fill the frame). All twelve share one role — interchangeable tunnel gallery frames — and all read as a cohesive, muted, high-contrast editorial / art-photography set that pops against the black stage. Concretely, the real set is a varied but tonally-linked mix:

  • Minimalist white sculptural still lifes — a smooth abstract organic form and soft creases on a near-white ground; palette almost entirely off-white / pale grey, very low contrast, luminous.
  • Grey-and-white marble / liquid-swirl textures — flowing veined stone-like abstraction in whites, silvers and charcoal-grey.
  • Silver product / packaging shots — e.g. a brushed aluminium tin on a soft grey seamless backdrop; metallic silver and neutral grey tones.
  • Motion-blurred figure — a person in mid-movement, streaked and soft; muted grey, taupe and off-white clothing against a blurred neutral background.
  • Ethereal beauty / portrait crop — a pale, backlit fair-haired figure with delicate translucent floral/butterfly adornments in a soft garden; pastel palette of cream, blush-pink and soft green with hazy warm light.
  • Backlit macro botanical — slender pale sprouts and leaves against a deep indigo / violet background — the one saturated, dark-background accent in an otherwise pale, neutral set.

The dominant overall palette is whites, silvers, greys and soft neutrals with occasional pastel and one deep-violet accent, so the frames glow when they resolve at the camera plane. They are grouped 4 per ring across 3 unique rings, which repeat to fill 6 rendered layers. Provide 12 files named sequentially (img1…img12); keep the same neutral, high-contrast, luminous-on-black feel across all of them. If fewer are available, repeat them in order.

Behavior notes

  • Wheel / trackpad only — no ScrollTrigger, no click, no keyboard. On a device without wheel events the tunnel sits at the initialScroll = 750 resting pose (still 3D, just static).
  • Infinite in both directions: the modulo wrap means you can fly forward or backward forever.
  • Desktop-oriented; sizing is in fixed px (radii 400/280, items 180×220) so the composition is tuned for a large viewport. No responsive breakpoints in the original.
  • Stage height uses 100svh so mobile browser chrome doesn't clip it.
  • No reduced-motion handling in the original; motion is entirely user-driven (nothing autoplays), though the lerp glide continues briefly after input stops.