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;— thisperspective:1000pxis 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-3dlets children live in real 3D space so theirtranslateZreads as depth..layer:position:absolute;(no width/height — a pure transform group). Sits at the tunnel center; JS animates itsz(translateZ)..item:position:absolute; width:180px; height:220px;— placed via inlineleft/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--overlayvariable.
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
.layerdiv. imageStartIndex = (i % contentLayerCount) * 4→ i:0→0, 1→4, 2→8, 3→0, 4→4, 5→8.- For
jfrom0to3: imageNumber = imageStartIndex + j + 1; ifimageNumber > CONFIG.totalImagesbreak.- 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
.itemwith inlineleft:${itemX}px; top:${itemY}px;, append an<img>(src = tunnel imageimageNumber) and a.item-overlaydiv. Append item → layer. - Append layer → tunnel. Push
{ el: layerEl, baseZ: -i * CONFIG.layerGap }into alayerDataarray. 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.07gives 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) % tunnelDepthis a positive modulo (JS%can go negative), wrapping any depth into[0, 15000). SubtractingtunnelDepthand addingexitPointre-centers that range to[−13500, 1500), so as a ring'szcrosses1500it 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.zmaps totranslateZ. The--overlaycustom property is set on the.layer, and each child.item-overlayinherits it viaopacity:var(--overlay).visibility:hiddenwhen 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 = 750resting 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
100svhso 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.