Infinite 3D Gallery Tunnel
Twenty-four procedurally painted posters spiral down a fog-filled tunnel; wheel, drag or swipe sets the camera's speed, idle autoplay keeps it drifting, and plane depths wrap modulo the loop so the archive never ends.
- WebGL & 3D
- threejs
- gsap
- canvas2d
- advanced
- heavy · WebGL
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1677 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Infinite 3D Gallery Tunnel
**What it does (visual):** A full-viewport dark scene. The viewer floats inside a tunnel built from 24 portrait posters (4:5), each a soft duotone image with a large thin serif index number ("01"…"24"), a project name and a tag baked into the picture. The posters are arranged on a spiral — every poster sits 2.9 units from the centre axis, rotated 1/7 of a turn from the previous one and 1.7 units deeper — and all face inward toward the camera. From the first frame the camera is drifting forward slowly (1.1 units/s): near posters grow and slide past the edges of the screen, far ones emerge from fog at the end of the tunnel (fog reaches full density 32 units away). Scrolling the wheel, dragging, or swiping up adds speed; the camera accelerates smoothly, coasts, and relaxes back to the idle drift over ~2s. Scrolling the other way reverses travel. The whole spiral screws gently as you move, and the camera rolls a few degrees with velocity. The pointer position pans and tilts the camera a little (parallax eased at 0.05; enabled: true). Bottom-left, a big serif counter ("07 / 24") and the current project name + tag update as posters pass, the name rising in from below (0.5s). The tunnel has no end: posters that pass behind the camera reappear in the fog at the far end.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), Canvas 2D for the procedural poster textures.
- GSAP 3.13+ with the Observer plugin (free). CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `Observer.min.js`; npm: `import { Observer } from "gsap/Observer"; gsap.registerPlugin(Observer);`
- Fonts: a variable serif for the index numerals (demo uses Fraunces 300), a sans and a mono for labels — loaded with `document.fonts.load()` before painting the textures.
**HTML structure:**
```html
<section class="tunnel">
<div class="tunnel__canvas"></div> <!-- renderer canvas appended here -->
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite"><span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span></div>
<p class="tunnel__caption"><span class="tunnel__name">Aurora Station</span><span class="tunnel__tag">Film · 2026</span></p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
**CSS requirements:**
- `.tunnel`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; touch-action: none; overscroll-behavior: contain; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.tunnel__canvas`: `position: absolute; inset: 0; cursor: grab;` (`:active { cursor: grabbing }`), its canvas `width: 100%; height: 100%; display: block`.
- `.tunnel::after`: vignette overlay, `radial-gradient(70% 70% at 50% 50%, transparent 55%, rgba(bg, .7) 100%)`, `pointer-events: none`.
- `.tunnel__header`: `position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none;` Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase, `letter-spacing: .14em`, muted.
- `.tunnel__hud`: `position: absolute; left: same; bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; display: grid; gap: .9rem; pointer-events: none;`
- `.tunnel__index`: display serif `font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em;` `.tunnel__total`: mono 12px muted, `letter-spacing: .1em`.
- `.tunnel__caption`: `display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08)); min-width: 14rem;` `.tunnel__name`: serif 400, `font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; will-change: transform, opacity`. `.tunnel__tag`: mono 11px uppercase muted.
- `.tunnel__hint`: `position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: same as hud;` mono 11px uppercase muted with a 6px lime dot (`::before`). Under 768px it moves to the left under the HUD.
**Animation spec (exact values):**
Constants: `COUNT = 24`, `SPACING = 1.7`, `RADIUS = 2.9`, `TURN = 2π/7`, `LOOP = COUNT × SPACING`, `NEAR = SPACING × 1.2`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, `PARALLAX = true`, fog `near 4 / far 32`.
1. **Textures** (one 512×640 canvas per poster): linear gradient `c2 → c1` corner to corner; subject by `i % 3` — (0) a disc: radial gradient `c3 → c3@66% → transparent` radius 230 at (60%, 40%) plus a solid 86px disc at 0.9 alpha; (1) a horizon: lower half gradient `c3@80% → c1 (8%) → c2` with a 3px `c3` line at 55% alpha; (2) five concentric arcs (`lineWidth 14 − 2k`, radii `90 + 34k`, alpha `0.9 − 0.17k`, from `1.08π` to `1.92π`) centred (50%, 62%). Then `overlay` a tiled 128² random grain at 0.35 alpha, a 1px `rgba(255,255,255,.22)` frame inset 20px, and text: index `300 118px serif` at (40, 150), name `500 26px sans` uppercase at (44, H−76), tag `400 17px mono` at (44, H−46) in `rgba(242,242,242,.7)`. Palettes (c1, c2, c3) cycle through 8 duotones: navy `#0b1530/#1e3a6e/#8fb3ff`, plum `#1a0b1f/#4a1f52/#d79cff`, moss `#0b1a12/#1f4a2e/#9fe3b0`, ember `#1f0c08/#6b2a16/#ffb27a`, sand `#1c1710/#6b5a34/#ffe2a8`, ink `#0d0d10/#2b2b36/#bdbdd6`, teal `#071a1c/#145a5f/#8ff0f0`, rose `#200a12/#6b2340/#ffa3c4`. `CanvasTexture` with `colorSpace = SRGBColorSpace`, `anisotropy 4`, mipmaps.
2. **Scene**: `WebGLRenderer({ antialias: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `scene.fog = new THREE.Fog(bg, 4, 32)`, clear colour = `--bg`. `PerspectiveCamera(58, aspect, 0.1, 80)` at the origin looking down −z. A `Group` holds all planes.
3. **Planes**: `PlaneGeometry(2.0, 2.5)` shared; per plane `MeshBasicMaterial({ map, transparent: true, fog: true, toneMapped: false, side: DoubleSide })`. Position `(cos(i·TURN)·RADIUS, sin(i·TURN)·RADIUS, z)`, then `mesh.lookAt(0, 0, 4.5)` so each faces the axis tilted toward the viewer.
4. **Layout (every frame)** — the infinite wrap: `m = ((i·SPACING − travel) mod LOOP + LOOP) mod LOOP`; `plane.position.z = NEAR − m`; `plane.material.opacity = smoothstep(m, 0, SPACING × 1.5)` (fades out over the last 1.5 spacings before passing the camera); `group.rotation.z = travel × 0.012`.
5. **Speed model (every frame, dt clamped to 50ms)**: `speed += (targetSpeed − speed) × 0.06`; `targetSpeed += (BASE_SPEED − targetSpeed) × 0.03` (BASE_SPEED = 1.1); `travel += speed × dt`. Initial `travel = SPACING × 0.4` so the first frame already shows posters at pleasing depths.
6. **Observer**: `Observer.create({ target: section, type: 'wheel,touch,pointer', preventDefault: true, dragMinimum: 3, onChangeY(self) { const dir = self.event.type === 'wheel' ? 1 : −1; targetSpeed = clamp(−14, 14, targetSpeed + dir × self.deltaY × 0.03); } })` — wheel down and drag/swipe up both travel forward.
7. **Parallax (every frame, only when `PARALLAX` = true is true)**: pointer normalised to −1…1 over the section. `camera.position.x → px × 0.45`, `camera.position.y → −py × 0.3`, `camera.rotation.y → −px × 0.07`, `camera.rotation.x → −py × 0.045`, `camera.rotation.z → speed × 0.004`; each eased with `+= (target − current) × 0.05`.
8. **Counter**: `idx = floor(travel / SPACING + 2) mod 24`; when it changes set `.tunnel__index` to `String(idx+1).padStart(2,'0')` and `gsap.fromTo('.tunnel__name', { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true })`, swapping the name/tag text in `onStart`.
9. **Intro**: `gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 })`; `gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 })`.
10. **Theme**: read `getComputedStyle(document.documentElement).getPropertyValue('--bg')` into a `THREE.Color` for fog + clear colour; re-run on a `MutationObserver` watching `data-theme`.
**Interaction:** Wheel: each 100px of delta adds `100 × 0.03` units/s to the target speed (capped at ±14), so a flick roughly doubles the drift and a hard scroll races through ~6 posters before coasting back to idle in about 2s. Drag (mouse) and swipe (touch) do the same with inverted sign; `preventDefault: true` keeps the page from scrolling under the tunnel. Pointer movement pans/tilts the camera (no click needed). Nothing is clickable in the demo; in production make each plane a raycast target that opens the project.
**Responsive behavior:** `gsap.matchMedia({ mobile: '(max-width: 767px)', touch: '(hover: none)' })`: under 768px the camera `fov` is 72 (instead of 58) so the ring still surrounds the viewer on a portrait screen; on touch devices pointer parallax is disabled (the finger is the drag input). The HUD type scales with `clamp()`; the hint moves under the counter on phones. `ResizeObserver` on the canvas wrapper updates renderer size + aspect.
**Accessibility:** With `prefers-reduced-motion: reduce`: `BASE_SPEED` is 0 (no autoplay), parallax and roll are off, and there is no rAF loop — the scene is rendered once after build/resize, and each Observer `onChangeY` moves `travel` directly by `deltaY × 0.02` and re-renders, so the archive is still browsable by scroll without continuous motion; the counter updates without the rise animation. The canvas wrapper is `aria-hidden`; the counter is an `aria-live="polite"` region so the current index/name is announced; the poster titles are also plain text in the HUD, not only baked into textures.
**Performance rules:** DPR ≤ `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). 24 draw calls, one shared geometry, 24 × 512×640 textures with mipmaps (~20 MB GPU). Only positions/opacity update per frame — no geometry rebuilds. Clamp `dt` to 0.05s so a background tab doesn't teleport the camera. Stop the rAF loop on `ml:pause` and `visibilitychange`, restart on `ml:resume`. Cleanup: stop the loop, `observer.kill()`, `ctx.revert()`, disconnect observers, remove listeners, dispose every texture/material, the geometry and the renderer, remove the canvas.
**Customization knobs:**
- `COUNT`, `SPACING` (1.7), `RADIUS` (2.9), `TURN` — density and spiral shape (`TURN = 2π/8` makes a flat ring of 8 per revolution).
- `BASE_SPEED` (1.1) idle drift; `INPUT_GAIN` (0.03) gesture sensitivity; `MAX_SPEED` (14).
- `SPEED_LERP` (0.06) / `RETURN_LERP` (0.03) — acceleration feel and coast length.
- Fog `near/far` (4/32) — how early posters dissolve.
- `PALETTES` / `WORKS` arrays — swap for real `<img>`-backed `TextureLoader` textures.
- Parallax on/off (true) and amplitudes (0.45 / 0.3 / 0.07 / 0.045).
**Acceptance criteria:**
- [ ] The camera drifts forward on its own from the first frame; posters appear from the fog and pass the screen edges without ever popping or clipping through the camera.
- [ ] Wheel/drag/swipe changes speed smoothly (no stepping) and the speed returns to idle within ~2s of the last input; reverse direction works.
- [ ] After travelling `24 × 1.7` units the scene is identical to the start — no visible seam.
- [ ] Pointer parallax is subtle and eased (0.05), never snapping.
- [ ] The counter and name update as posters pass; the name rises in over 0.5s.
- [ ] Light theme: fog and clear colour match the light background.
- [ ] Reduced motion: static frame, scroll still steps through posters, no autoplay.
- [ ] No console errors; textures use real fonts (no fallback glyphs). Add this effect to my existing site/section: **Infinite 3D Gallery Tunnel**. Inside a `100vh` `.tunnel` section, mount a Three.js `WebGLRenderer` (DPR ≤ 2) with `scene.fog = new THREE.Fog(bg, 4, 32)` and a `PerspectiveCamera(58)` at the origin. Create 24 `PlaneGeometry(2.0, 2.5)` meshes textured with my project images (`MeshBasicMaterial { map, transparent: true, fog: true }`), placed at `(cos(i·2π/7)·2.9, sin(i·2π/7)·2.9, z)` and `lookAt(0, 0, 4.5)`. With `SPACING = 1.7` and `LOOP = 24·SPACING`, every frame set `z = SPACING·1.2 − ((i·SPACING − travel) mod LOOP)` and `opacity = smoothstep(m, 0, SPACING·1.5)` so posters wrap infinitely. Drive `travel` with a speed model: `speed += (target − speed)·0.06`, `target += (1.1 − target)·0.03`, `travel += speed·dt`; feed it from GSAP `Observer.create({ type: 'wheel,touch,pointer', preventDefault: true, onChangeY: self => target = clamp(−14, 14, target + (wheel ? 1 : −1)·self.deltaY·0.03) })`. If true is true add pointer parallax (`camera.position.x → px·0.45`, `rotation.y → −px·0.07`, lerp 0.05) and a counter that shows `floor(travel/SPACING + 2) mod 24`. Respect `prefers-reduced-motion` (no autoplay, render on input only), pause the loop when hidden, dispose on unmount. Keep my fonts, colours and layout. ### EFFECT (React / Next.js): Infinite 3D Gallery Tunnel
Build **Infinite 3D Gallery Tunnel** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react three`
- File: `components/Infinite3DGalleryTunnel.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { Observer } from "gsap/Observer";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP, Observer);
export default function Infinite3DGalleryTunnel() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="tunnel">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`Infinite3DGalleryTunnel.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
- Three.js: create renderer/scene inside a \`useEffect\` with a cleanup that calls \`renderer.dispose()\` and disposes geometries/materials; cap DPR at \`Math.min(devicePixelRatio, 2)\`.
---
### EFFECT: Infinite 3D Gallery Tunnel
**What it does (visual):** A full-viewport dark scene. The viewer floats inside a tunnel built from 24 portrait posters (4:5), each a soft duotone image with a large thin serif index number ("01"…"24"), a project name and a tag baked into the picture. The posters are arranged on a spiral — every poster sits 2.9 units from the centre axis, rotated 1/7 of a turn from the previous one and 1.7 units deeper — and all face inward toward the camera. From the first frame the camera is drifting forward slowly (1.1 units/s): near posters grow and slide past the edges of the screen, far ones emerge from fog at the end of the tunnel (fog reaches full density 32 units away). Scrolling the wheel, dragging, or swiping up adds speed; the camera accelerates smoothly, coasts, and relaxes back to the idle drift over ~2s. Scrolling the other way reverses travel. The whole spiral screws gently as you move, and the camera rolls a few degrees with velocity. The pointer position pans and tilts the camera a little (parallax eased at 0.05; enabled: true). Bottom-left, a big serif counter ("07 / 24") and the current project name + tag update as posters pass, the name rising in from below (0.5s). The tunnel has no end: posters that pass behind the camera reappear in the fog at the far end.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), Canvas 2D for the procedural poster textures.
- GSAP 3.13+ with the Observer plugin (free). CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `Observer.min.js`; npm: `import { Observer } from "gsap/Observer"; gsap.registerPlugin(Observer);`
- Fonts: a variable serif for the index numerals (demo uses Fraunces 300), a sans and a mono for labels — loaded with `document.fonts.load()` before painting the textures.
**HTML structure:**
```html
<section class="tunnel">
<div class="tunnel__canvas"></div> <!-- renderer canvas appended here -->
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite"><span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span></div>
<p class="tunnel__caption"><span class="tunnel__name">Aurora Station</span><span class="tunnel__tag">Film · 2026</span></p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
**CSS requirements:**
- `.tunnel`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; touch-action: none; overscroll-behavior: contain; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.tunnel__canvas`: `position: absolute; inset: 0; cursor: grab;` (`:active { cursor: grabbing }`), its canvas `width: 100%; height: 100%; display: block`.
- `.tunnel::after`: vignette overlay, `radial-gradient(70% 70% at 50% 50%, transparent 55%, rgba(bg, .7) 100%)`, `pointer-events: none`.
- `.tunnel__header`: `position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none;` Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase, `letter-spacing: .14em`, muted.
- `.tunnel__hud`: `position: absolute; left: same; bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; display: grid; gap: .9rem; pointer-events: none;`
- `.tunnel__index`: display serif `font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em;` `.tunnel__total`: mono 12px muted, `letter-spacing: .1em`.
- `.tunnel__caption`: `display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08)); min-width: 14rem;` `.tunnel__name`: serif 400, `font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; will-change: transform, opacity`. `.tunnel__tag`: mono 11px uppercase muted.
- `.tunnel__hint`: `position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: same as hud;` mono 11px uppercase muted with a 6px lime dot (`::before`). Under 768px it moves to the left under the HUD.
**Animation spec (exact values):**
Constants: `COUNT = 24`, `SPACING = 1.7`, `RADIUS = 2.9`, `TURN = 2π/7`, `LOOP = COUNT × SPACING`, `NEAR = SPACING × 1.2`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, `PARALLAX = true`, fog `near 4 / far 32`.
1. **Textures** (one 512×640 canvas per poster): linear gradient `c2 → c1` corner to corner; subject by `i % 3` — (0) a disc: radial gradient `c3 → c3@66% → transparent` radius 230 at (60%, 40%) plus a solid 86px disc at 0.9 alpha; (1) a horizon: lower half gradient `c3@80% → c1 (8%) → c2` with a 3px `c3` line at 55% alpha; (2) five concentric arcs (`lineWidth 14 − 2k`, radii `90 + 34k`, alpha `0.9 − 0.17k`, from `1.08π` to `1.92π`) centred (50%, 62%). Then `overlay` a tiled 128² random grain at 0.35 alpha, a 1px `rgba(255,255,255,.22)` frame inset 20px, and text: index `300 118px serif` at (40, 150), name `500 26px sans` uppercase at (44, H−76), tag `400 17px mono` at (44, H−46) in `rgba(242,242,242,.7)`. Palettes (c1, c2, c3) cycle through 8 duotones: navy `#0b1530/#1e3a6e/#8fb3ff`, plum `#1a0b1f/#4a1f52/#d79cff`, moss `#0b1a12/#1f4a2e/#9fe3b0`, ember `#1f0c08/#6b2a16/#ffb27a`, sand `#1c1710/#6b5a34/#ffe2a8`, ink `#0d0d10/#2b2b36/#bdbdd6`, teal `#071a1c/#145a5f/#8ff0f0`, rose `#200a12/#6b2340/#ffa3c4`. `CanvasTexture` with `colorSpace = SRGBColorSpace`, `anisotropy 4`, mipmaps.
2. **Scene**: `WebGLRenderer({ antialias: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `scene.fog = new THREE.Fog(bg, 4, 32)`, clear colour = `--bg`. `PerspectiveCamera(58, aspect, 0.1, 80)` at the origin looking down −z. A `Group` holds all planes.
3. **Planes**: `PlaneGeometry(2.0, 2.5)` shared; per plane `MeshBasicMaterial({ map, transparent: true, fog: true, toneMapped: false, side: DoubleSide })`. Position `(cos(i·TURN)·RADIUS, sin(i·TURN)·RADIUS, z)`, then `mesh.lookAt(0, 0, 4.5)` so each faces the axis tilted toward the viewer.
4. **Layout (every frame)** — the infinite wrap: `m = ((i·SPACING − travel) mod LOOP + LOOP) mod LOOP`; `plane.position.z = NEAR − m`; `plane.material.opacity = smoothstep(m, 0, SPACING × 1.5)` (fades out over the last 1.5 spacings before passing the camera); `group.rotation.z = travel × 0.012`.
5. **Speed model (every frame, dt clamped to 50ms)**: `speed += (targetSpeed − speed) × 0.06`; `targetSpeed += (BASE_SPEED − targetSpeed) × 0.03` (BASE_SPEED = 1.1); `travel += speed × dt`. Initial `travel = SPACING × 0.4` so the first frame already shows posters at pleasing depths.
6. **Observer**: `Observer.create({ target: section, type: 'wheel,touch,pointer', preventDefault: true, dragMinimum: 3, onChangeY(self) { const dir = self.event.type === 'wheel' ? 1 : −1; targetSpeed = clamp(−14, 14, targetSpeed + dir × self.deltaY × 0.03); } })` — wheel down and drag/swipe up both travel forward.
7. **Parallax (every frame, only when `PARALLAX` = true is true)**: pointer normalised to −1…1 over the section. `camera.position.x → px × 0.45`, `camera.position.y → −py × 0.3`, `camera.rotation.y → −px × 0.07`, `camera.rotation.x → −py × 0.045`, `camera.rotation.z → speed × 0.004`; each eased with `+= (target − current) × 0.05`.
8. **Counter**: `idx = floor(travel / SPACING + 2) mod 24`; when it changes set `.tunnel__index` to `String(idx+1).padStart(2,'0')` and `gsap.fromTo('.tunnel__name', { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true })`, swapping the name/tag text in `onStart`.
9. **Intro**: `gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 })`; `gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 })`.
10. **Theme**: read `getComputedStyle(document.documentElement).getPropertyValue('--bg')` into a `THREE.Color` for fog + clear colour; re-run on a `MutationObserver` watching `data-theme`.
**Interaction:** Wheel: each 100px of delta adds `100 × 0.03` units/s to the target speed (capped at ±14), so a flick roughly doubles the drift and a hard scroll races through ~6 posters before coasting back to idle in about 2s. Drag (mouse) and swipe (touch) do the same with inverted sign; `preventDefault: true` keeps the page from scrolling under the tunnel. Pointer movement pans/tilts the camera (no click needed). Nothing is clickable in the demo; in production make each plane a raycast target that opens the project.
**Responsive behavior:** `gsap.matchMedia({ mobile: '(max-width: 767px)', touch: '(hover: none)' })`: under 768px the camera `fov` is 72 (instead of 58) so the ring still surrounds the viewer on a portrait screen; on touch devices pointer parallax is disabled (the finger is the drag input). The HUD type scales with `clamp()`; the hint moves under the counter on phones. `ResizeObserver` on the canvas wrapper updates renderer size + aspect.
**Accessibility:** With `prefers-reduced-motion: reduce`: `BASE_SPEED` is 0 (no autoplay), parallax and roll are off, and there is no rAF loop — the scene is rendered once after build/resize, and each Observer `onChangeY` moves `travel` directly by `deltaY × 0.02` and re-renders, so the archive is still browsable by scroll without continuous motion; the counter updates without the rise animation. The canvas wrapper is `aria-hidden`; the counter is an `aria-live="polite"` region so the current index/name is announced; the poster titles are also plain text in the HUD, not only baked into textures.
**Performance rules:** DPR ≤ `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). 24 draw calls, one shared geometry, 24 × 512×640 textures with mipmaps (~20 MB GPU). Only positions/opacity update per frame — no geometry rebuilds. Clamp `dt` to 0.05s so a background tab doesn't teleport the camera. Stop the rAF loop on `ml:pause` and `visibilitychange`, restart on `ml:resume`. Cleanup: stop the loop, `observer.kill()`, `ctx.revert()`, disconnect observers, remove listeners, dispose every texture/material, the geometry and the renderer, remove the canvas.
**Customization knobs:**
- `COUNT`, `SPACING` (1.7), `RADIUS` (2.9), `TURN` — density and spiral shape (`TURN = 2π/8` makes a flat ring of 8 per revolution).
- `BASE_SPEED` (1.1) idle drift; `INPUT_GAIN` (0.03) gesture sensitivity; `MAX_SPEED` (14).
- `SPEED_LERP` (0.06) / `RETURN_LERP` (0.03) — acceleration feel and coast length.
- Fog `near/far` (4/32) — how early posters dissolve.
- `PALETTES` / `WORKS` arrays — swap for real `<img>`-backed `TextureLoader` textures.
- Parallax on/off (true) and amplitudes (0.45 / 0.3 / 0.07 / 0.045).
**Acceptance criteria:**
- [ ] The camera drifts forward on its own from the first frame; posters appear from the fog and pass the screen edges without ever popping or clipping through the camera.
- [ ] Wheel/drag/swipe changes speed smoothly (no stepping) and the speed returns to idle within ~2s of the last input; reverse direction works.
- [ ] After travelling `24 × 1.7` units the scene is identical to the start — no visible seam.
- [ ] Pointer parallax is subtle and eased (0.05), never snapping.
- [ ] The counter and name update as posters pass; the name rises in over 0.5s.
- [ ] Light theme: fog and clear colour match the light background.
- [ ] Reduced motion: static frame, scroll still steps through posters, no autoplay.
- [ ] No console errors; textures use real fonts (no fallback glyphs).
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="tunnel" aria-label="Infinite 3D gallery tunnel">
<div class="tunnel__canvas" aria-hidden="true"></div>
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite">
<span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span>
</div>
<p class="tunnel__caption">
<span class="tunnel__name">Aurora Station</span>
<span class="tunnel__tag">Film · 2026</span>
</p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
```css
.tunnel { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; overscroll-behavior: contain; }
.tunnel__canvas { position: absolute; inset: 0; cursor: grab; }
.tunnel__canvas:active { cursor: grabbing; }
.tunnel__canvas canvas { width: 100%; height: 100%; display: block; }
/* soft vignette so the tunnel walls dissolve into the page */
.tunnel::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 70% at 50% 50%, transparent 55%, color-mix(in srgb, var(--bg) 70%, transparent) 100%); }
.tunnel__header { position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; }
.tunnel__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1rem; }
.tunnel__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.tunnel__hud { position: absolute; left: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; display: grid; gap: .9rem; }
.tunnel__counter { display: flex; align-items: baseline; gap: .6rem; }
.tunnel__index { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em; }
.tunnel__total { font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.tunnel__caption { display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line); min-width: 14rem; }
.tunnel__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; line-height: 1.1; will-change: transform, opacity; }
.tunnel__tag { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.tunnel__hint { position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none; }
.tunnel__hint::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: .7rem; vertical-align: middle; }
@media (max-width: 767px) {
.tunnel__hint { right: auto; left: clamp(1.25rem, 6vw, 5rem); bottom: .9rem; }
.tunnel__hud { bottom: 3.2rem; }
.tunnel__caption { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tunnel__hint::before { display: none; }
}
```
```js
// Infinite 3D gallery tunnel — 24 textured planes spiral down -z; Observer drives speed; z wraps modulo the loop length.
import * as THREE from 'three';
gsap.registerPlugin(Observer);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { baseSpeed: 1.1, inputGain: 0.03, spacing: 1.7, radius: 2.9, fogFar: 32, parallax: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 24; // planes in the loop
const TURN = (Math.PI * 2) / 7; // angular step per plane (7 per revolution → spiral, never a flat ring)
const MAX_SPEED = 14;
const SPEED_LERP = 0.06; // speed → targetSpeed per frame
const RETURN_LERP = 0.03; // targetSpeed → baseSpeed per frame
const PARALLAX_LERP = 0.05;
const FOG_NEAR = 4;
// Knob-driven geometry of the loop (re-read in build())
let SPACING = DEFAULTS.spacing; // z distance between consecutive planes
let RADIUS = DEFAULTS.radius; // distance of each plane from the tunnel axis
let LOOP = COUNT * SPACING; // travel wraps modulo this
let NEAR = SPACING * 1.2; // planes pass this far behind the camera before wrapping to the far end
let INPUT_GAIN = DEFAULTS.inputGain; // speed added per pixel of wheel / drag
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.tunnel');
const mount = section.querySelector('.tunnel__canvas');
const indexEl = section.querySelector('.tunnel__index');
const nameEl = section.querySelector('.tunnel__name');
const tagEl = section.querySelector('.tunnel__tag');
const WORKS = [
['Aurora Station', 'Film · 2026'], ['Salt Flats', 'Photography · 2026'], ['Night Market', 'Identity · 2025'], ['Low Tide', 'Editorial · 2025'],
['Glasshouse', 'Architecture · 2026'], ['Cinder', 'Campaign · 2024'], ['Blue Hour', 'Film · 2025'], ['Paper Moon', 'Exhibition · 2024'],
['Meridian', 'Product · 2026'], ['Still Water', 'Photography · 2025'], ['Terrace', 'Architecture · 2025'], ['Velvet Signal', 'Music · 2024'],
['Ferrous', 'Industrial · 2026'], ['Half Light', 'Editorial · 2024'], ['Orchard', 'Identity · 2026'], ['Monsoon', 'Film · 2024'],
['Quarry', 'Architecture · 2024'], ['Soft Static', 'Music · 2025'], ['Harbour', 'Photography · 2026'], ['Lantern', 'Campaign · 2025'],
['Northline', 'Product · 2025'], ['Ember Field', 'Exhibition · 2026'], ['Tideglass', 'Identity · 2024'], ['Last Light', 'Film · 2026'],
];
const PALETTES = [
['#0b1530', '#1e3a6e', '#8fb3ff'], ['#1a0b1f', '#4a1f52', '#d79cff'], ['#0b1a12', '#1f4a2e', '#9fe3b0'], ['#1f0c08', '#6b2a16', '#ffb27a'],
['#1c1710', '#6b5a34', '#ffe2a8'], ['#0d0d10', '#2b2b36', '#bdbdd6'], ['#071a1c', '#145a5f', '#8ff0f0'], ['#200a12', '#6b2340', '#ffa3c4'],
];
// ---------------------------------------------------------------- procedural "photographs"
function makeGrain() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d'); const img = g.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const v = 110 + Math.random() * 90; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
g.putImageData(img, 0, 0); return c;
}
const grain = makeGrain();
function makeTexture(i) {
const W = 512, H = 640;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
const [c1, c2, c3] = PALETTES[i % PALETTES.length];
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, c2); base.addColorStop(1, c1);
g.fillStyle = base; g.fillRect(0, 0, W, H);
// subject: alternates between a sun disc, a horizon band and a halo arc
const kind = i % 3;
if (kind === 0) {
const r = g.createRadialGradient(W * 0.6, H * 0.4, 10, W * 0.6, H * 0.4, 230);
r.addColorStop(0, c3); r.addColorStop(0.45, c3 + 'aa'); r.addColorStop(1, 'transparent');
g.fillStyle = r; g.fillRect(0, 0, W, H);
g.fillStyle = c3; g.globalAlpha = 0.9; g.beginPath(); g.arc(W * 0.6, H * 0.4, 86, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1;
} else if (kind === 1) {
const h = g.createLinearGradient(0, H * 0.5, 0, H); h.addColorStop(0, c3 + 'cc'); h.addColorStop(0.08, c1); h.addColorStop(1, c2);
g.fillStyle = h; g.fillRect(0, H * 0.5, W, H * 0.5);
g.fillStyle = c3; g.globalAlpha = 0.55; g.fillRect(0, H * 0.5 - 2, W, 3); g.globalAlpha = 1;
} else {
g.strokeStyle = c3; g.lineCap = 'round';
for (let k = 0; k < 5; k++) { g.globalAlpha = 0.9 - k * 0.17; g.lineWidth = 14 - k * 2; g.beginPath(); g.arc(W * 0.5, H * 0.62, 90 + k * 34, Math.PI * 1.08, Math.PI * 1.92); g.stroke(); }
g.globalAlpha = 1;
}
// film grain + hairline frame
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.35; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 1; g.strokeRect(20.5, 20.5, W - 41, H - 41);
// typography baked into the image
const [name, tag] = WORKS[i];
g.fillStyle = '#f2f2f2'; g.textBaseline = 'alphabetic';
g.font = '300 118px Fraunces, Georgia, serif'; g.fillText(String(i + 1).padStart(2, '0'), 40, 150);
g.font = '500 26px "Inter Tight", system-ui, sans-serif'; g.fillText(name.toUpperCase(), 44, H - 76);
g.font = '400 17px "JetBrains Mono", ui-monospace, monospace'; g.fillStyle = 'rgba(242,242,242,.7)'; g.fillText(tag.toUpperCase(), 44, H - 46);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter;
return tex;
}
// ---------------------------------------------------------------- three (renderer, scene, textures persist across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0a0a, FOG_NEAR, DEFAULTS.fogFar);
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 80);
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.PlaneGeometry(2.0, 2.5);
const planes = [];
const textures = [];
let needsRender = true;
function syncTheme() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0a0a0a';
const col = new THREE.Color(bg);
scene.fog.color.copy(col);
renderer.setClearColor(col, 1);
needsRender = true;
}
const themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
syncTheme();
// ---------------------------------------------------------------- state
let travel = DEFAULTS.spacing * 0.4; // distance travelled down the tunnel (wraps modulo LOOP)
let speed = reduced ? 0 : DEFAULTS.baseSpeed;
let targetSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
let baseSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
const pointer = { x: 0, y: 0 };
let parallaxOn = !reduced; // media-query part (touch devices / reduced motion)
let parallaxKnob = DEFAULTS.parallax;
let currentIndex = -1;
function mod(n, m) { return ((n % m) + m) % m; }
// place each plane on the spiral at the current RADIUS (z is overwritten every frame by layout())
function placePlane(mesh, i) {
const a = i * TURN;
mesh.position.set(Math.cos(a) * RADIUS, Math.sin(a) * RADIUS, 0);
mesh.lookAt(0, 0, 4.5); // face the tunnel axis, tilted slightly toward the viewer
mesh.position.z = -(i * SPACING);
}
function layout() {
for (let i = 0; i < planes.length; i++) {
const p = planes[i];
const m = mod(i * SPACING - travel, LOOP); // 0 = right behind the camera, LOOP = far end
p.position.z = NEAR - m;
const near = THREE.MathUtils.smoothstep(m, 0, SPACING * 1.5); // fade out as a plane passes the camera
p.material.opacity = near;
}
group.rotation.z = travel * 0.012; // the spiral slowly screws forward as you travel
}
function updateCounter() {
const idx = mod(Math.floor(travel / SPACING + 2), COUNT);
if (idx === currentIndex) return;
currentIndex = idx;
indexEl.textContent = String(idx + 1).padStart(2, '0');
const [name, tag] = WORKS[idx];
if (reduced) { nameEl.textContent = name; tagEl.textContent = tag; return; }
gsap.fromTo(nameEl, { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true, onStart: () => { nameEl.textContent = name; tagEl.textContent = tag; } });
}
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = ((e.clientY - r.top) / r.height) * 2 - 1;
};
section.addEventListener('pointermove', onPointer);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 });
gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 });
});
// ---------------------------------------------------------------- build: read knobs, apply to the scene, (re)create Observer + matchMedia
let ctx, observer;
function build() {
observer?.kill();
ctx?.revert();
// knobs → loop geometry / speed model / fog
SPACING = K('spacing');
RADIUS = K('radius');
LOOP = COUNT * SPACING;
NEAR = SPACING * 1.2;
INPUT_GAIN = K('inputGain');
parallaxKnob = !!K('parallax');
baseSpeed = reduced ? 0 : K('baseSpeed');
targetSpeed = reduced ? 0 : baseSpeed;
scene.fog.far = K('fogFar');
travel = mod(travel, LOOP);
planes.forEach(placePlane);
ctx = gsap.context(() => {
observer = Observer.create({
target: section,
type: 'wheel,touch,pointer',
preventDefault: true,
dragMinimum: 3,
onChangeY(self) {
const dir = self.event.type === 'wheel' ? 1 : -1; // drag/swipe up = travel forward (natural scroll)
targetSpeed = gsap.utils.clamp(-MAX_SPEED, MAX_SPEED, targetSpeed + dir * self.deltaY * INPUT_GAIN);
if (reduced) { travel += dir * self.deltaY * 0.02; layout(); updateCounter(); needsRender = true; }
},
});
const mm = gsap.matchMedia();
mm.add({ mobile: '(max-width: 767px)', touch: '(hover: none)' }, (c) => {
const { mobile, touch } = c.conditions;
camera.fov = mobile ? 72 : 58; camera.updateProjectionMatrix();
parallaxOn = !touch && !reduced;
needsRender = true;
return () => { camera.fov = 58; camera.updateProjectionMatrix(); parallaxOn = !reduced; };
});
});
currentIndex = -1;
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
// ---------------------------------------------------------------- create planes once fonts are ready (so baked titles don't fall back)
async function createPlanes() {
try {
await document.fonts.ready;
await Promise.all([document.fonts.load('300 118px Fraunces'), document.fonts.load('500 26px "Inter Tight"'), document.fonts.load('400 17px "JetBrains Mono"')]);
} catch { /* fallbacks are fine */ }
for (let i = 0; i < COUNT; i++) {
const tex = makeTexture(i);
textures.push(tex);
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, fog: true, toneMapped: false, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, mat);
placePlane(mesh, i);
group.add(mesh);
planes.push(mesh);
}
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
build();
createPlanes();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function render() { renderer.render(scene, camera); needsRender = false; }
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speed += (targetSpeed - speed) * SPEED_LERP;
targetSpeed += (baseSpeed - targetSpeed) * RETURN_LERP;
travel += speed * dt;
if (travel > LOOP * 1e3 || travel < -LOOP * 1e3) travel = mod(travel, LOOP); // keep the float small on very long sessions
const par = parallaxOn && parallaxKnob;
const px = par ? pointer.x : 0, py = par ? pointer.y : 0;
camera.position.x += (px * 0.45 - camera.position.x) * PARALLAX_LERP;
camera.position.y += (-py * 0.3 - camera.position.y) * PARALLAX_LERP;
camera.rotation.y += (-px * 0.07 - camera.rotation.y) * PARALLAX_LERP;
camera.rotation.x += (-py * 0.045 - camera.rotation.x) * PARALLAX_LERP;
camera.rotation.z += (speed * 0.004 - camera.rotation.z) * PARALLAX_LERP; // a hint of roll with velocity
layout();
updateCounter();
render();
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) render();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
start();
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: render on demand only (input re-renders inside onChangeY)
if (reduced) {
const onDemand = () => { if (needsRender) render(); };
section.addEventListener('pointerup', onDemand);
}
// Playground: re-read knobs and rebuild the input/loop parameters (renderer + textures are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
observer?.kill();
ctx?.revert();
introCtx.revert();
ro.disconnect();
themeObserver.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onPointer);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
textures.forEach((t) => t.dispose());
planes.forEach((p) => p.material.dispose());
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (single HTML file): Infinite 3D Gallery Tunnel
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Infinite 3D Gallery Tunnel** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Infinite 3D Gallery Tunnel</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Observer.min.js"></script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186/examples/jsm/"}}</script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Infinite 3D Gallery Tunnel
**What it does (visual):** A full-viewport dark scene. The viewer floats inside a tunnel built from 24 portrait posters (4:5), each a soft duotone image with a large thin serif index number ("01"…"24"), a project name and a tag baked into the picture. The posters are arranged on a spiral — every poster sits 2.9 units from the centre axis, rotated 1/7 of a turn from the previous one and 1.7 units deeper — and all face inward toward the camera. From the first frame the camera is drifting forward slowly (1.1 units/s): near posters grow and slide past the edges of the screen, far ones emerge from fog at the end of the tunnel (fog reaches full density 32 units away). Scrolling the wheel, dragging, or swiping up adds speed; the camera accelerates smoothly, coasts, and relaxes back to the idle drift over ~2s. Scrolling the other way reverses travel. The whole spiral screws gently as you move, and the camera rolls a few degrees with velocity. The pointer position pans and tilts the camera a little (parallax eased at 0.05; enabled: true). Bottom-left, a big serif counter ("07 / 24") and the current project name + tag update as posters pass, the name rising in from below (0.5s). The tunnel has no end: posters that pass behind the camera reappear in the fog at the far end.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), Canvas 2D for the procedural poster textures.
- GSAP 3.13+ with the Observer plugin (free). CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `Observer.min.js`; npm: `import { Observer } from "gsap/Observer"; gsap.registerPlugin(Observer);`
- Fonts: a variable serif for the index numerals (demo uses Fraunces 300), a sans and a mono for labels — loaded with `document.fonts.load()` before painting the textures.
**HTML structure:**
```html
<section class="tunnel">
<div class="tunnel__canvas"></div> <!-- renderer canvas appended here -->
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite"><span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span></div>
<p class="tunnel__caption"><span class="tunnel__name">Aurora Station</span><span class="tunnel__tag">Film · 2026</span></p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
**CSS requirements:**
- `.tunnel`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; touch-action: none; overscroll-behavior: contain; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.tunnel__canvas`: `position: absolute; inset: 0; cursor: grab;` (`:active { cursor: grabbing }`), its canvas `width: 100%; height: 100%; display: block`.
- `.tunnel::after`: vignette overlay, `radial-gradient(70% 70% at 50% 50%, transparent 55%, rgba(bg, .7) 100%)`, `pointer-events: none`.
- `.tunnel__header`: `position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none;` Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase, `letter-spacing: .14em`, muted.
- `.tunnel__hud`: `position: absolute; left: same; bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; display: grid; gap: .9rem; pointer-events: none;`
- `.tunnel__index`: display serif `font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em;` `.tunnel__total`: mono 12px muted, `letter-spacing: .1em`.
- `.tunnel__caption`: `display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08)); min-width: 14rem;` `.tunnel__name`: serif 400, `font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; will-change: transform, opacity`. `.tunnel__tag`: mono 11px uppercase muted.
- `.tunnel__hint`: `position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: same as hud;` mono 11px uppercase muted with a 6px lime dot (`::before`). Under 768px it moves to the left under the HUD.
**Animation spec (exact values):**
Constants: `COUNT = 24`, `SPACING = 1.7`, `RADIUS = 2.9`, `TURN = 2π/7`, `LOOP = COUNT × SPACING`, `NEAR = SPACING × 1.2`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, `PARALLAX = true`, fog `near 4 / far 32`.
1. **Textures** (one 512×640 canvas per poster): linear gradient `c2 → c1` corner to corner; subject by `i % 3` — (0) a disc: radial gradient `c3 → c3@66% → transparent` radius 230 at (60%, 40%) plus a solid 86px disc at 0.9 alpha; (1) a horizon: lower half gradient `c3@80% → c1 (8%) → c2` with a 3px `c3` line at 55% alpha; (2) five concentric arcs (`lineWidth 14 − 2k`, radii `90 + 34k`, alpha `0.9 − 0.17k`, from `1.08π` to `1.92π`) centred (50%, 62%). Then `overlay` a tiled 128² random grain at 0.35 alpha, a 1px `rgba(255,255,255,.22)` frame inset 20px, and text: index `300 118px serif` at (40, 150), name `500 26px sans` uppercase at (44, H−76), tag `400 17px mono` at (44, H−46) in `rgba(242,242,242,.7)`. Palettes (c1, c2, c3) cycle through 8 duotones: navy `#0b1530/#1e3a6e/#8fb3ff`, plum `#1a0b1f/#4a1f52/#d79cff`, moss `#0b1a12/#1f4a2e/#9fe3b0`, ember `#1f0c08/#6b2a16/#ffb27a`, sand `#1c1710/#6b5a34/#ffe2a8`, ink `#0d0d10/#2b2b36/#bdbdd6`, teal `#071a1c/#145a5f/#8ff0f0`, rose `#200a12/#6b2340/#ffa3c4`. `CanvasTexture` with `colorSpace = SRGBColorSpace`, `anisotropy 4`, mipmaps.
2. **Scene**: `WebGLRenderer({ antialias: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `scene.fog = new THREE.Fog(bg, 4, 32)`, clear colour = `--bg`. `PerspectiveCamera(58, aspect, 0.1, 80)` at the origin looking down −z. A `Group` holds all planes.
3. **Planes**: `PlaneGeometry(2.0, 2.5)` shared; per plane `MeshBasicMaterial({ map, transparent: true, fog: true, toneMapped: false, side: DoubleSide })`. Position `(cos(i·TURN)·RADIUS, sin(i·TURN)·RADIUS, z)`, then `mesh.lookAt(0, 0, 4.5)` so each faces the axis tilted toward the viewer.
4. **Layout (every frame)** — the infinite wrap: `m = ((i·SPACING − travel) mod LOOP + LOOP) mod LOOP`; `plane.position.z = NEAR − m`; `plane.material.opacity = smoothstep(m, 0, SPACING × 1.5)` (fades out over the last 1.5 spacings before passing the camera); `group.rotation.z = travel × 0.012`.
5. **Speed model (every frame, dt clamped to 50ms)**: `speed += (targetSpeed − speed) × 0.06`; `targetSpeed += (BASE_SPEED − targetSpeed) × 0.03` (BASE_SPEED = 1.1); `travel += speed × dt`. Initial `travel = SPACING × 0.4` so the first frame already shows posters at pleasing depths.
6. **Observer**: `Observer.create({ target: section, type: 'wheel,touch,pointer', preventDefault: true, dragMinimum: 3, onChangeY(self) { const dir = self.event.type === 'wheel' ? 1 : −1; targetSpeed = clamp(−14, 14, targetSpeed + dir × self.deltaY × 0.03); } })` — wheel down and drag/swipe up both travel forward.
7. **Parallax (every frame, only when `PARALLAX` = true is true)**: pointer normalised to −1…1 over the section. `camera.position.x → px × 0.45`, `camera.position.y → −py × 0.3`, `camera.rotation.y → −px × 0.07`, `camera.rotation.x → −py × 0.045`, `camera.rotation.z → speed × 0.004`; each eased with `+= (target − current) × 0.05`.
8. **Counter**: `idx = floor(travel / SPACING + 2) mod 24`; when it changes set `.tunnel__index` to `String(idx+1).padStart(2,'0')` and `gsap.fromTo('.tunnel__name', { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true })`, swapping the name/tag text in `onStart`.
9. **Intro**: `gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 })`; `gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 })`.
10. **Theme**: read `getComputedStyle(document.documentElement).getPropertyValue('--bg')` into a `THREE.Color` for fog + clear colour; re-run on a `MutationObserver` watching `data-theme`.
**Interaction:** Wheel: each 100px of delta adds `100 × 0.03` units/s to the target speed (capped at ±14), so a flick roughly doubles the drift and a hard scroll races through ~6 posters before coasting back to idle in about 2s. Drag (mouse) and swipe (touch) do the same with inverted sign; `preventDefault: true` keeps the page from scrolling under the tunnel. Pointer movement pans/tilts the camera (no click needed). Nothing is clickable in the demo; in production make each plane a raycast target that opens the project.
**Responsive behavior:** `gsap.matchMedia({ mobile: '(max-width: 767px)', touch: '(hover: none)' })`: under 768px the camera `fov` is 72 (instead of 58) so the ring still surrounds the viewer on a portrait screen; on touch devices pointer parallax is disabled (the finger is the drag input). The HUD type scales with `clamp()`; the hint moves under the counter on phones. `ResizeObserver` on the canvas wrapper updates renderer size + aspect.
**Accessibility:** With `prefers-reduced-motion: reduce`: `BASE_SPEED` is 0 (no autoplay), parallax and roll are off, and there is no rAF loop — the scene is rendered once after build/resize, and each Observer `onChangeY` moves `travel` directly by `deltaY × 0.02` and re-renders, so the archive is still browsable by scroll without continuous motion; the counter updates without the rise animation. The canvas wrapper is `aria-hidden`; the counter is an `aria-live="polite"` region so the current index/name is announced; the poster titles are also plain text in the HUD, not only baked into textures.
**Performance rules:** DPR ≤ `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). 24 draw calls, one shared geometry, 24 × 512×640 textures with mipmaps (~20 MB GPU). Only positions/opacity update per frame — no geometry rebuilds. Clamp `dt` to 0.05s so a background tab doesn't teleport the camera. Stop the rAF loop on `ml:pause` and `visibilitychange`, restart on `ml:resume`. Cleanup: stop the loop, `observer.kill()`, `ctx.revert()`, disconnect observers, remove listeners, dispose every texture/material, the geometry and the renderer, remove the canvas.
**Customization knobs:**
- `COUNT`, `SPACING` (1.7), `RADIUS` (2.9), `TURN` — density and spiral shape (`TURN = 2π/8` makes a flat ring of 8 per revolution).
- `BASE_SPEED` (1.1) idle drift; `INPUT_GAIN` (0.03) gesture sensitivity; `MAX_SPEED` (14).
- `SPEED_LERP` (0.06) / `RETURN_LERP` (0.03) — acceleration feel and coast length.
- Fog `near/far` (4/32) — how early posters dissolve.
- `PALETTES` / `WORKS` arrays — swap for real `<img>`-backed `TextureLoader` textures.
- Parallax on/off (true) and amplitudes (0.45 / 0.3 / 0.07 / 0.045).
**Acceptance criteria:**
- [ ] The camera drifts forward on its own from the first frame; posters appear from the fog and pass the screen edges without ever popping or clipping through the camera.
- [ ] Wheel/drag/swipe changes speed smoothly (no stepping) and the speed returns to idle within ~2s of the last input; reverse direction works.
- [ ] After travelling `24 × 1.7` units the scene is identical to the start — no visible seam.
- [ ] Pointer parallax is subtle and eased (0.05), never snapping.
- [ ] The counter and name update as posters pass; the name rises in over 0.5s.
- [ ] Light theme: fog and clear colour match the light background.
- [ ] Reduced motion: static frame, scroll still steps through posters, no autoplay.
- [ ] No console errors; textures use real fonts (no fallback glyphs).
---
**Reference implementation (use these exact values and class names):**
```html
<section class="tunnel" aria-label="Infinite 3D gallery tunnel">
<div class="tunnel__canvas" aria-hidden="true"></div>
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite">
<span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span>
</div>
<p class="tunnel__caption">
<span class="tunnel__name">Aurora Station</span>
<span class="tunnel__tag">Film · 2026</span>
</p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
```css
.tunnel { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; overscroll-behavior: contain; }
.tunnel__canvas { position: absolute; inset: 0; cursor: grab; }
.tunnel__canvas:active { cursor: grabbing; }
.tunnel__canvas canvas { width: 100%; height: 100%; display: block; }
/* soft vignette so the tunnel walls dissolve into the page */
.tunnel::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 70% at 50% 50%, transparent 55%, color-mix(in srgb, var(--bg) 70%, transparent) 100%); }
.tunnel__header { position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; }
.tunnel__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1rem; }
.tunnel__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.tunnel__hud { position: absolute; left: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; display: grid; gap: .9rem; }
.tunnel__counter { display: flex; align-items: baseline; gap: .6rem; }
.tunnel__index { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em; }
.tunnel__total { font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.tunnel__caption { display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line); min-width: 14rem; }
.tunnel__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; line-height: 1.1; will-change: transform, opacity; }
.tunnel__tag { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.tunnel__hint { position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none; }
.tunnel__hint::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: .7rem; vertical-align: middle; }
@media (max-width: 767px) {
.tunnel__hint { right: auto; left: clamp(1.25rem, 6vw, 5rem); bottom: .9rem; }
.tunnel__hud { bottom: 3.2rem; }
.tunnel__caption { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tunnel__hint::before { display: none; }
}
```
```js
// Infinite 3D gallery tunnel — 24 textured planes spiral down -z; Observer drives speed; z wraps modulo the loop length.
import * as THREE from 'three';
gsap.registerPlugin(Observer);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { baseSpeed: 1.1, inputGain: 0.03, spacing: 1.7, radius: 2.9, fogFar: 32, parallax: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 24; // planes in the loop
const TURN = (Math.PI * 2) / 7; // angular step per plane (7 per revolution → spiral, never a flat ring)
const MAX_SPEED = 14;
const SPEED_LERP = 0.06; // speed → targetSpeed per frame
const RETURN_LERP = 0.03; // targetSpeed → baseSpeed per frame
const PARALLAX_LERP = 0.05;
const FOG_NEAR = 4;
// Knob-driven geometry of the loop (re-read in build())
let SPACING = DEFAULTS.spacing; // z distance between consecutive planes
let RADIUS = DEFAULTS.radius; // distance of each plane from the tunnel axis
let LOOP = COUNT * SPACING; // travel wraps modulo this
let NEAR = SPACING * 1.2; // planes pass this far behind the camera before wrapping to the far end
let INPUT_GAIN = DEFAULTS.inputGain; // speed added per pixel of wheel / drag
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.tunnel');
const mount = section.querySelector('.tunnel__canvas');
const indexEl = section.querySelector('.tunnel__index');
const nameEl = section.querySelector('.tunnel__name');
const tagEl = section.querySelector('.tunnel__tag');
const WORKS = [
['Aurora Station', 'Film · 2026'], ['Salt Flats', 'Photography · 2026'], ['Night Market', 'Identity · 2025'], ['Low Tide', 'Editorial · 2025'],
['Glasshouse', 'Architecture · 2026'], ['Cinder', 'Campaign · 2024'], ['Blue Hour', 'Film · 2025'], ['Paper Moon', 'Exhibition · 2024'],
['Meridian', 'Product · 2026'], ['Still Water', 'Photography · 2025'], ['Terrace', 'Architecture · 2025'], ['Velvet Signal', 'Music · 2024'],
['Ferrous', 'Industrial · 2026'], ['Half Light', 'Editorial · 2024'], ['Orchard', 'Identity · 2026'], ['Monsoon', 'Film · 2024'],
['Quarry', 'Architecture · 2024'], ['Soft Static', 'Music · 2025'], ['Harbour', 'Photography · 2026'], ['Lantern', 'Campaign · 2025'],
['Northline', 'Product · 2025'], ['Ember Field', 'Exhibition · 2026'], ['Tideglass', 'Identity · 2024'], ['Last Light', 'Film · 2026'],
];
const PALETTES = [
['#0b1530', '#1e3a6e', '#8fb3ff'], ['#1a0b1f', '#4a1f52', '#d79cff'], ['#0b1a12', '#1f4a2e', '#9fe3b0'], ['#1f0c08', '#6b2a16', '#ffb27a'],
['#1c1710', '#6b5a34', '#ffe2a8'], ['#0d0d10', '#2b2b36', '#bdbdd6'], ['#071a1c', '#145a5f', '#8ff0f0'], ['#200a12', '#6b2340', '#ffa3c4'],
];
// ---------------------------------------------------------------- procedural "photographs"
function makeGrain() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d'); const img = g.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const v = 110 + Math.random() * 90; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
g.putImageData(img, 0, 0); return c;
}
const grain = makeGrain();
function makeTexture(i) {
const W = 512, H = 640;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
const [c1, c2, c3] = PALETTES[i % PALETTES.length];
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, c2); base.addColorStop(1, c1);
g.fillStyle = base; g.fillRect(0, 0, W, H);
// subject: alternates between a sun disc, a horizon band and a halo arc
const kind = i % 3;
if (kind === 0) {
const r = g.createRadialGradient(W * 0.6, H * 0.4, 10, W * 0.6, H * 0.4, 230);
r.addColorStop(0, c3); r.addColorStop(0.45, c3 + 'aa'); r.addColorStop(1, 'transparent');
g.fillStyle = r; g.fillRect(0, 0, W, H);
g.fillStyle = c3; g.globalAlpha = 0.9; g.beginPath(); g.arc(W * 0.6, H * 0.4, 86, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1;
} else if (kind === 1) {
const h = g.createLinearGradient(0, H * 0.5, 0, H); h.addColorStop(0, c3 + 'cc'); h.addColorStop(0.08, c1); h.addColorStop(1, c2);
g.fillStyle = h; g.fillRect(0, H * 0.5, W, H * 0.5);
g.fillStyle = c3; g.globalAlpha = 0.55; g.fillRect(0, H * 0.5 - 2, W, 3); g.globalAlpha = 1;
} else {
g.strokeStyle = c3; g.lineCap = 'round';
for (let k = 0; k < 5; k++) { g.globalAlpha = 0.9 - k * 0.17; g.lineWidth = 14 - k * 2; g.beginPath(); g.arc(W * 0.5, H * 0.62, 90 + k * 34, Math.PI * 1.08, Math.PI * 1.92); g.stroke(); }
g.globalAlpha = 1;
}
// film grain + hairline frame
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.35; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 1; g.strokeRect(20.5, 20.5, W - 41, H - 41);
// typography baked into the image
const [name, tag] = WORKS[i];
g.fillStyle = '#f2f2f2'; g.textBaseline = 'alphabetic';
g.font = '300 118px Fraunces, Georgia, serif'; g.fillText(String(i + 1).padStart(2, '0'), 40, 150);
g.font = '500 26px "Inter Tight", system-ui, sans-serif'; g.fillText(name.toUpperCase(), 44, H - 76);
g.font = '400 17px "JetBrains Mono", ui-monospace, monospace'; g.fillStyle = 'rgba(242,242,242,.7)'; g.fillText(tag.toUpperCase(), 44, H - 46);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter;
return tex;
}
// ---------------------------------------------------------------- three (renderer, scene, textures persist across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0a0a, FOG_NEAR, DEFAULTS.fogFar);
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 80);
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.PlaneGeometry(2.0, 2.5);
const planes = [];
const textures = [];
let needsRender = true;
function syncTheme() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0a0a0a';
const col = new THREE.Color(bg);
scene.fog.color.copy(col);
renderer.setClearColor(col, 1);
needsRender = true;
}
const themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
syncTheme();
// ---------------------------------------------------------------- state
let travel = DEFAULTS.spacing * 0.4; // distance travelled down the tunnel (wraps modulo LOOP)
let speed = reduced ? 0 : DEFAULTS.baseSpeed;
let targetSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
let baseSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
const pointer = { x: 0, y: 0 };
let parallaxOn = !reduced; // media-query part (touch devices / reduced motion)
let parallaxKnob = DEFAULTS.parallax;
let currentIndex = -1;
function mod(n, m) { return ((n % m) + m) % m; }
// place each plane on the spiral at the current RADIUS (z is overwritten every frame by layout())
function placePlane(mesh, i) {
const a = i * TURN;
mesh.position.set(Math.cos(a) * RADIUS, Math.sin(a) * RADIUS, 0);
mesh.lookAt(0, 0, 4.5); // face the tunnel axis, tilted slightly toward the viewer
mesh.position.z = -(i * SPACING);
}
function layout() {
for (let i = 0; i < planes.length; i++) {
const p = planes[i];
const m = mod(i * SPACING - travel, LOOP); // 0 = right behind the camera, LOOP = far end
p.position.z = NEAR - m;
const near = THREE.MathUtils.smoothstep(m, 0, SPACING * 1.5); // fade out as a plane passes the camera
p.material.opacity = near;
}
group.rotation.z = travel * 0.012; // the spiral slowly screws forward as you travel
}
function updateCounter() {
const idx = mod(Math.floor(travel / SPACING + 2), COUNT);
if (idx === currentIndex) return;
currentIndex = idx;
indexEl.textContent = String(idx + 1).padStart(2, '0');
const [name, tag] = WORKS[idx];
if (reduced) { nameEl.textContent = name; tagEl.textContent = tag; return; }
gsap.fromTo(nameEl, { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true, onStart: () => { nameEl.textContent = name; tagEl.textContent = tag; } });
}
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = ((e.clientY - r.top) / r.height) * 2 - 1;
};
section.addEventListener('pointermove', onPointer);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 });
gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 });
});
// ---------------------------------------------------------------- build: read knobs, apply to the scene, (re)create Observer + matchMedia
let ctx, observer;
function build() {
observer?.kill();
ctx?.revert();
// knobs → loop geometry / speed model / fog
SPACING = K('spacing');
RADIUS = K('radius');
LOOP = COUNT * SPACING;
NEAR = SPACING * 1.2;
INPUT_GAIN = K('inputGain');
parallaxKnob = !!K('parallax');
baseSpeed = reduced ? 0 : K('baseSpeed');
targetSpeed = reduced ? 0 : baseSpeed;
scene.fog.far = K('fogFar');
travel = mod(travel, LOOP);
planes.forEach(placePlane);
ctx = gsap.context(() => {
observer = Observer.create({
target: section,
type: 'wheel,touch,pointer',
preventDefault: true,
dragMinimum: 3,
onChangeY(self) {
const dir = self.event.type === 'wheel' ? 1 : -1; // drag/swipe up = travel forward (natural scroll)
targetSpeed = gsap.utils.clamp(-MAX_SPEED, MAX_SPEED, targetSpeed + dir * self.deltaY * INPUT_GAIN);
if (reduced) { travel += dir * self.deltaY * 0.02; layout(); updateCounter(); needsRender = true; }
},
});
const mm = gsap.matchMedia();
mm.add({ mobile: '(max-width: 767px)', touch: '(hover: none)' }, (c) => {
const { mobile, touch } = c.conditions;
camera.fov = mobile ? 72 : 58; camera.updateProjectionMatrix();
parallaxOn = !touch && !reduced;
needsRender = true;
return () => { camera.fov = 58; camera.updateProjectionMatrix(); parallaxOn = !reduced; };
});
});
currentIndex = -1;
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
// ---------------------------------------------------------------- create planes once fonts are ready (so baked titles don't fall back)
async function createPlanes() {
try {
await document.fonts.ready;
await Promise.all([document.fonts.load('300 118px Fraunces'), document.fonts.load('500 26px "Inter Tight"'), document.fonts.load('400 17px "JetBrains Mono"')]);
} catch { /* fallbacks are fine */ }
for (let i = 0; i < COUNT; i++) {
const tex = makeTexture(i);
textures.push(tex);
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, fog: true, toneMapped: false, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, mat);
placePlane(mesh, i);
group.add(mesh);
planes.push(mesh);
}
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
build();
createPlanes();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function render() { renderer.render(scene, camera); needsRender = false; }
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speed += (targetSpeed - speed) * SPEED_LERP;
targetSpeed += (baseSpeed - targetSpeed) * RETURN_LERP;
travel += speed * dt;
if (travel > LOOP * 1e3 || travel < -LOOP * 1e3) travel = mod(travel, LOOP); // keep the float small on very long sessions
const par = parallaxOn && parallaxKnob;
const px = par ? pointer.x : 0, py = par ? pointer.y : 0;
camera.position.x += (px * 0.45 - camera.position.x) * PARALLAX_LERP;
camera.position.y += (-py * 0.3 - camera.position.y) * PARALLAX_LERP;
camera.rotation.y += (-px * 0.07 - camera.rotation.y) * PARALLAX_LERP;
camera.rotation.x += (-py * 0.045 - camera.rotation.x) * PARALLAX_LERP;
camera.rotation.z += (speed * 0.004 - camera.rotation.z) * PARALLAX_LERP; // a hint of roll with velocity
layout();
updateCounter();
render();
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) render();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
start();
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: render on demand only (input re-renders inside onChangeY)
if (reduced) {
const onDemand = () => { if (needsRender) render(); };
section.addEventListener('pointerup', onDemand);
}
// Playground: re-read knobs and rebuild the input/loop parameters (renderer + textures are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
observer?.kill();
ctx?.revert();
introCtx.revert();
ro.disconnect();
themeObserver.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onPointer);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
textures.forEach((t) => t.dispose());
planes.forEach((p) => p.material.dispose());
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (Framer & Webflow notes): Infinite 3D Gallery Tunnel
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withInfinite3DGalleryTunnel(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
- Three.js in Framer: fine inside a Code Component; dispose the renderer in the effect cleanup.
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `tunnel`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Observer.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Infinite 3D Gallery Tunnel
**What it does (visual):** A full-viewport dark scene. The viewer floats inside a tunnel built from 24 portrait posters (4:5), each a soft duotone image with a large thin serif index number ("01"…"24"), a project name and a tag baked into the picture. The posters are arranged on a spiral — every poster sits 2.9 units from the centre axis, rotated 1/7 of a turn from the previous one and 1.7 units deeper — and all face inward toward the camera. From the first frame the camera is drifting forward slowly (1.1 units/s): near posters grow and slide past the edges of the screen, far ones emerge from fog at the end of the tunnel (fog reaches full density 32 units away). Scrolling the wheel, dragging, or swiping up adds speed; the camera accelerates smoothly, coasts, and relaxes back to the idle drift over ~2s. Scrolling the other way reverses travel. The whole spiral screws gently as you move, and the camera rolls a few degrees with velocity. The pointer position pans and tilts the camera a little (parallax eased at 0.05; enabled: true). Bottom-left, a big serif counter ("07 / 24") and the current project name + tag update as posters pass, the name rising in from below (0.5s). The tunnel has no end: posters that pass behind the camera reappear in the fog at the far end.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), Canvas 2D for the procedural poster textures.
- GSAP 3.13+ with the Observer plugin (free). CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `Observer.min.js`; npm: `import { Observer } from "gsap/Observer"; gsap.registerPlugin(Observer);`
- Fonts: a variable serif for the index numerals (demo uses Fraunces 300), a sans and a mono for labels — loaded with `document.fonts.load()` before painting the textures.
**HTML structure:**
```html
<section class="tunnel">
<div class="tunnel__canvas"></div> <!-- renderer canvas appended here -->
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite"><span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span></div>
<p class="tunnel__caption"><span class="tunnel__name">Aurora Station</span><span class="tunnel__tag">Film · 2026</span></p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
```
**CSS requirements:**
- `.tunnel`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; touch-action: none; overscroll-behavior: contain; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.tunnel__canvas`: `position: absolute; inset: 0; cursor: grab;` (`:active { cursor: grabbing }`), its canvas `width: 100%; height: 100%; display: block`.
- `.tunnel::after`: vignette overlay, `radial-gradient(70% 70% at 50% 50%, transparent 55%, rgba(bg, .7) 100%)`, `pointer-events: none`.
- `.tunnel__header`: `position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none;` Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase, `letter-spacing: .14em`, muted.
- `.tunnel__hud`: `position: absolute; left: same; bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; display: grid; gap: .9rem; pointer-events: none;`
- `.tunnel__index`: display serif `font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em;` `.tunnel__total`: mono 12px muted, `letter-spacing: .1em`.
- `.tunnel__caption`: `display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08)); min-width: 14rem;` `.tunnel__name`: serif 400, `font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; will-change: transform, opacity`. `.tunnel__tag`: mono 11px uppercase muted.
- `.tunnel__hint`: `position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: same as hud;` mono 11px uppercase muted with a 6px lime dot (`::before`). Under 768px it moves to the left under the HUD.
**Animation spec (exact values):**
Constants: `COUNT = 24`, `SPACING = 1.7`, `RADIUS = 2.9`, `TURN = 2π/7`, `LOOP = COUNT × SPACING`, `NEAR = SPACING × 1.2`, `BASE_SPEED = 1.1`, `MAX_SPEED = 14`, `INPUT_GAIN = 0.03`, `SPEED_LERP = 0.06`, `RETURN_LERP = 0.03`, `PARALLAX_LERP = 0.05`, `PARALLAX = true`, fog `near 4 / far 32`.
1. **Textures** (one 512×640 canvas per poster): linear gradient `c2 → c1` corner to corner; subject by `i % 3` — (0) a disc: radial gradient `c3 → c3@66% → transparent` radius 230 at (60%, 40%) plus a solid 86px disc at 0.9 alpha; (1) a horizon: lower half gradient `c3@80% → c1 (8%) → c2` with a 3px `c3` line at 55% alpha; (2) five concentric arcs (`lineWidth 14 − 2k`, radii `90 + 34k`, alpha `0.9 − 0.17k`, from `1.08π` to `1.92π`) centred (50%, 62%). Then `overlay` a tiled 128² random grain at 0.35 alpha, a 1px `rgba(255,255,255,.22)` frame inset 20px, and text: index `300 118px serif` at (40, 150), name `500 26px sans` uppercase at (44, H−76), tag `400 17px mono` at (44, H−46) in `rgba(242,242,242,.7)`. Palettes (c1, c2, c3) cycle through 8 duotones: navy `#0b1530/#1e3a6e/#8fb3ff`, plum `#1a0b1f/#4a1f52/#d79cff`, moss `#0b1a12/#1f4a2e/#9fe3b0`, ember `#1f0c08/#6b2a16/#ffb27a`, sand `#1c1710/#6b5a34/#ffe2a8`, ink `#0d0d10/#2b2b36/#bdbdd6`, teal `#071a1c/#145a5f/#8ff0f0`, rose `#200a12/#6b2340/#ffa3c4`. `CanvasTexture` with `colorSpace = SRGBColorSpace`, `anisotropy 4`, mipmaps.
2. **Scene**: `WebGLRenderer({ antialias: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `scene.fog = new THREE.Fog(bg, 4, 32)`, clear colour = `--bg`. `PerspectiveCamera(58, aspect, 0.1, 80)` at the origin looking down −z. A `Group` holds all planes.
3. **Planes**: `PlaneGeometry(2.0, 2.5)` shared; per plane `MeshBasicMaterial({ map, transparent: true, fog: true, toneMapped: false, side: DoubleSide })`. Position `(cos(i·TURN)·RADIUS, sin(i·TURN)·RADIUS, z)`, then `mesh.lookAt(0, 0, 4.5)` so each faces the axis tilted toward the viewer.
4. **Layout (every frame)** — the infinite wrap: `m = ((i·SPACING − travel) mod LOOP + LOOP) mod LOOP`; `plane.position.z = NEAR − m`; `plane.material.opacity = smoothstep(m, 0, SPACING × 1.5)` (fades out over the last 1.5 spacings before passing the camera); `group.rotation.z = travel × 0.012`.
5. **Speed model (every frame, dt clamped to 50ms)**: `speed += (targetSpeed − speed) × 0.06`; `targetSpeed += (BASE_SPEED − targetSpeed) × 0.03` (BASE_SPEED = 1.1); `travel += speed × dt`. Initial `travel = SPACING × 0.4` so the first frame already shows posters at pleasing depths.
6. **Observer**: `Observer.create({ target: section, type: 'wheel,touch,pointer', preventDefault: true, dragMinimum: 3, onChangeY(self) { const dir = self.event.type === 'wheel' ? 1 : −1; targetSpeed = clamp(−14, 14, targetSpeed + dir × self.deltaY × 0.03); } })` — wheel down and drag/swipe up both travel forward.
7. **Parallax (every frame, only when `PARALLAX` = true is true)**: pointer normalised to −1…1 over the section. `camera.position.x → px × 0.45`, `camera.position.y → −py × 0.3`, `camera.rotation.y → −px × 0.07`, `camera.rotation.x → −py × 0.045`, `camera.rotation.z → speed × 0.004`; each eased with `+= (target − current) × 0.05`.
8. **Counter**: `idx = floor(travel / SPACING + 2) mod 24`; when it changes set `.tunnel__index` to `String(idx+1).padStart(2,'0')` and `gsap.fromTo('.tunnel__name', { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true })`, swapping the name/tag text in `onStart`.
9. **Intro**: `gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 })`; `gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 })`.
10. **Theme**: read `getComputedStyle(document.documentElement).getPropertyValue('--bg')` into a `THREE.Color` for fog + clear colour; re-run on a `MutationObserver` watching `data-theme`.
**Interaction:** Wheel: each 100px of delta adds `100 × 0.03` units/s to the target speed (capped at ±14), so a flick roughly doubles the drift and a hard scroll races through ~6 posters before coasting back to idle in about 2s. Drag (mouse) and swipe (touch) do the same with inverted sign; `preventDefault: true` keeps the page from scrolling under the tunnel. Pointer movement pans/tilts the camera (no click needed). Nothing is clickable in the demo; in production make each plane a raycast target that opens the project.
**Responsive behavior:** `gsap.matchMedia({ mobile: '(max-width: 767px)', touch: '(hover: none)' })`: under 768px the camera `fov` is 72 (instead of 58) so the ring still surrounds the viewer on a portrait screen; on touch devices pointer parallax is disabled (the finger is the drag input). The HUD type scales with `clamp()`; the hint moves under the counter on phones. `ResizeObserver` on the canvas wrapper updates renderer size + aspect.
**Accessibility:** With `prefers-reduced-motion: reduce`: `BASE_SPEED` is 0 (no autoplay), parallax and roll are off, and there is no rAF loop — the scene is rendered once after build/resize, and each Observer `onChangeY` moves `travel` directly by `deltaY × 0.02` and re-renders, so the archive is still browsable by scroll without continuous motion; the counter updates without the rise animation. The canvas wrapper is `aria-hidden`; the counter is an `aria-live="polite"` region so the current index/name is announced; the poster titles are also plain text in the HUD, not only baked into textures.
**Performance rules:** DPR ≤ `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). 24 draw calls, one shared geometry, 24 × 512×640 textures with mipmaps (~20 MB GPU). Only positions/opacity update per frame — no geometry rebuilds. Clamp `dt` to 0.05s so a background tab doesn't teleport the camera. Stop the rAF loop on `ml:pause` and `visibilitychange`, restart on `ml:resume`. Cleanup: stop the loop, `observer.kill()`, `ctx.revert()`, disconnect observers, remove listeners, dispose every texture/material, the geometry and the renderer, remove the canvas.
**Customization knobs:**
- `COUNT`, `SPACING` (1.7), `RADIUS` (2.9), `TURN` — density and spiral shape (`TURN = 2π/8` makes a flat ring of 8 per revolution).
- `BASE_SPEED` (1.1) idle drift; `INPUT_GAIN` (0.03) gesture sensitivity; `MAX_SPEED` (14).
- `SPEED_LERP` (0.06) / `RETURN_LERP` (0.03) — acceleration feel and coast length.
- Fog `near/far` (4/32) — how early posters dissolve.
- `PALETTES` / `WORKS` arrays — swap for real `<img>`-backed `TextureLoader` textures.
- Parallax on/off (true) and amplitudes (0.45 / 0.3 / 0.07 / 0.045).
**Acceptance criteria:**
- [ ] The camera drifts forward on its own from the first frame; posters appear from the fog and pass the screen edges without ever popping or clipping through the camera.
- [ ] Wheel/drag/swipe changes speed smoothly (no stepping) and the speed returns to idle within ~2s of the last input; reverse direction works.
- [ ] After travelling `24 × 1.7` units the scene is identical to the start — no visible seam.
- [ ] Pointer parallax is subtle and eased (0.05), never snapping.
- [ ] The counter and name update as posters pass; the name rises in over 0.5s.
- [ ] Light theme: fog and clear colour match the light background.
- [ ] Reduced motion: static frame, scroll still steps through posters, no autoplay.
- [ ] No console errors; textures use real fonts (no fallback glyphs).
Source
Full source code
<section class="tunnel" aria-label="Infinite 3D gallery tunnel">
<div class="tunnel__canvas" aria-hidden="true"></div>
<header class="tunnel__header">
<p class="tunnel__eyebrow">WebGL · Observer · Infinite loop</p>
<h1 class="tunnel__title">Archive,<br>without end</h1>
</header>
<div class="tunnel__hud">
<div class="tunnel__counter" aria-live="polite">
<span class="tunnel__index">01</span><span class="tunnel__total">/ 24</span>
</div>
<p class="tunnel__caption">
<span class="tunnel__name">Aurora Station</span>
<span class="tunnel__tag">Film · 2026</span>
</p>
</div>
<p class="tunnel__hint">Scroll, drag or swipe to travel</p>
</section>
.tunnel { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; overscroll-behavior: contain; }
.tunnel__canvas { position: absolute; inset: 0; cursor: grab; }
.tunnel__canvas:active { cursor: grabbing; }
.tunnel__canvas canvas { width: 100%; height: 100%; display: block; }
/* soft vignette so the tunnel walls dissolve into the page */
.tunnel::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 70% at 50% 50%, transparent 55%, color-mix(in srgb, var(--bg) 70%, transparent) 100%); }
.tunnel__header { position: absolute; left: clamp(1.25rem, 6vw, 5rem); top: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; }
.tunnel__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1rem; }
.tunnel__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.tunnel__hud { position: absolute; left: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; pointer-events: none; display: grid; gap: .9rem; }
.tunnel__counter { display: flex; align-items: baseline; gap: .6rem; }
.tunnel__index { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 7vw, 6rem); line-height: .8; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; min-width: 1.2em; }
.tunnel__total { font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.tunnel__caption { display: grid; gap: .3rem; padding-top: .9rem; border-top: 1px solid var(--line); min-width: 14rem; }
.tunnel__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 2vw, 1.75rem); letter-spacing: -0.02em; line-height: 1.1; will-change: transform, opacity; }
.tunnel__tag { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.tunnel__hint { position: absolute; right: clamp(1.25rem, 6vw, 5rem); bottom: clamp(1.5rem, 4vw, 3.5rem); z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none; }
.tunnel__hint::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: .7rem; vertical-align: middle; }
@media (max-width: 767px) {
.tunnel__hint { right: auto; left: clamp(1.25rem, 6vw, 5rem); bottom: .9rem; }
.tunnel__hud { bottom: 3.2rem; }
.tunnel__caption { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tunnel__hint::before { display: none; }
}
// Infinite 3D gallery tunnel — 24 textured planes spiral down -z; Observer drives speed; z wraps modulo the loop length.
import * as THREE from 'three';
gsap.registerPlugin(Observer);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { baseSpeed: 1.1, inputGain: 0.03, spacing: 1.7, radius: 2.9, fogFar: 32, parallax: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 24; // planes in the loop
const TURN = (Math.PI * 2) / 7; // angular step per plane (7 per revolution → spiral, never a flat ring)
const MAX_SPEED = 14;
const SPEED_LERP = 0.06; // speed → targetSpeed per frame
const RETURN_LERP = 0.03; // targetSpeed → baseSpeed per frame
const PARALLAX_LERP = 0.05;
const FOG_NEAR = 4;
// Knob-driven geometry of the loop (re-read in build())
let SPACING = DEFAULTS.spacing; // z distance between consecutive planes
let RADIUS = DEFAULTS.radius; // distance of each plane from the tunnel axis
let LOOP = COUNT * SPACING; // travel wraps modulo this
let NEAR = SPACING * 1.2; // planes pass this far behind the camera before wrapping to the far end
let INPUT_GAIN = DEFAULTS.inputGain; // speed added per pixel of wheel / drag
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.tunnel');
const mount = section.querySelector('.tunnel__canvas');
const indexEl = section.querySelector('.tunnel__index');
const nameEl = section.querySelector('.tunnel__name');
const tagEl = section.querySelector('.tunnel__tag');
const WORKS = [
['Aurora Station', 'Film · 2026'], ['Salt Flats', 'Photography · 2026'], ['Night Market', 'Identity · 2025'], ['Low Tide', 'Editorial · 2025'],
['Glasshouse', 'Architecture · 2026'], ['Cinder', 'Campaign · 2024'], ['Blue Hour', 'Film · 2025'], ['Paper Moon', 'Exhibition · 2024'],
['Meridian', 'Product · 2026'], ['Still Water', 'Photography · 2025'], ['Terrace', 'Architecture · 2025'], ['Velvet Signal', 'Music · 2024'],
['Ferrous', 'Industrial · 2026'], ['Half Light', 'Editorial · 2024'], ['Orchard', 'Identity · 2026'], ['Monsoon', 'Film · 2024'],
['Quarry', 'Architecture · 2024'], ['Soft Static', 'Music · 2025'], ['Harbour', 'Photography · 2026'], ['Lantern', 'Campaign · 2025'],
['Northline', 'Product · 2025'], ['Ember Field', 'Exhibition · 2026'], ['Tideglass', 'Identity · 2024'], ['Last Light', 'Film · 2026'],
];
const PALETTES = [
['#0b1530', '#1e3a6e', '#8fb3ff'], ['#1a0b1f', '#4a1f52', '#d79cff'], ['#0b1a12', '#1f4a2e', '#9fe3b0'], ['#1f0c08', '#6b2a16', '#ffb27a'],
['#1c1710', '#6b5a34', '#ffe2a8'], ['#0d0d10', '#2b2b36', '#bdbdd6'], ['#071a1c', '#145a5f', '#8ff0f0'], ['#200a12', '#6b2340', '#ffa3c4'],
];
// ---------------------------------------------------------------- procedural "photographs"
function makeGrain() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d'); const img = g.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const v = 110 + Math.random() * 90; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
g.putImageData(img, 0, 0); return c;
}
const grain = makeGrain();
function makeTexture(i) {
const W = 512, H = 640;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
const [c1, c2, c3] = PALETTES[i % PALETTES.length];
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, c2); base.addColorStop(1, c1);
g.fillStyle = base; g.fillRect(0, 0, W, H);
// subject: alternates between a sun disc, a horizon band and a halo arc
const kind = i % 3;
if (kind === 0) {
const r = g.createRadialGradient(W * 0.6, H * 0.4, 10, W * 0.6, H * 0.4, 230);
r.addColorStop(0, c3); r.addColorStop(0.45, c3 + 'aa'); r.addColorStop(1, 'transparent');
g.fillStyle = r; g.fillRect(0, 0, W, H);
g.fillStyle = c3; g.globalAlpha = 0.9; g.beginPath(); g.arc(W * 0.6, H * 0.4, 86, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1;
} else if (kind === 1) {
const h = g.createLinearGradient(0, H * 0.5, 0, H); h.addColorStop(0, c3 + 'cc'); h.addColorStop(0.08, c1); h.addColorStop(1, c2);
g.fillStyle = h; g.fillRect(0, H * 0.5, W, H * 0.5);
g.fillStyle = c3; g.globalAlpha = 0.55; g.fillRect(0, H * 0.5 - 2, W, 3); g.globalAlpha = 1;
} else {
g.strokeStyle = c3; g.lineCap = 'round';
for (let k = 0; k < 5; k++) { g.globalAlpha = 0.9 - k * 0.17; g.lineWidth = 14 - k * 2; g.beginPath(); g.arc(W * 0.5, H * 0.62, 90 + k * 34, Math.PI * 1.08, Math.PI * 1.92); g.stroke(); }
g.globalAlpha = 1;
}
// film grain + hairline frame
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.35; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 1; g.strokeRect(20.5, 20.5, W - 41, H - 41);
// typography baked into the image
const [name, tag] = WORKS[i];
g.fillStyle = '#f2f2f2'; g.textBaseline = 'alphabetic';
g.font = '300 118px Fraunces, Georgia, serif'; g.fillText(String(i + 1).padStart(2, '0'), 40, 150);
g.font = '500 26px "Inter Tight", system-ui, sans-serif'; g.fillText(name.toUpperCase(), 44, H - 76);
g.font = '400 17px "JetBrains Mono", ui-monospace, monospace'; g.fillStyle = 'rgba(242,242,242,.7)'; g.fillText(tag.toUpperCase(), 44, H - 46);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter;
return tex;
}
// ---------------------------------------------------------------- three (renderer, scene, textures persist across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0a0a, FOG_NEAR, DEFAULTS.fogFar);
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 80);
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.PlaneGeometry(2.0, 2.5);
const planes = [];
const textures = [];
let needsRender = true;
function syncTheme() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0a0a0a';
const col = new THREE.Color(bg);
scene.fog.color.copy(col);
renderer.setClearColor(col, 1);
needsRender = true;
}
const themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
syncTheme();
// ---------------------------------------------------------------- state
let travel = DEFAULTS.spacing * 0.4; // distance travelled down the tunnel (wraps modulo LOOP)
let speed = reduced ? 0 : DEFAULTS.baseSpeed;
let targetSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
let baseSpeed = reduced ? 0 : DEFAULTS.baseSpeed;
const pointer = { x: 0, y: 0 };
let parallaxOn = !reduced; // media-query part (touch devices / reduced motion)
let parallaxKnob = DEFAULTS.parallax;
let currentIndex = -1;
function mod(n, m) { return ((n % m) + m) % m; }
// place each plane on the spiral at the current RADIUS (z is overwritten every frame by layout())
function placePlane(mesh, i) {
const a = i * TURN;
mesh.position.set(Math.cos(a) * RADIUS, Math.sin(a) * RADIUS, 0);
mesh.lookAt(0, 0, 4.5); // face the tunnel axis, tilted slightly toward the viewer
mesh.position.z = -(i * SPACING);
}
function layout() {
for (let i = 0; i < planes.length; i++) {
const p = planes[i];
const m = mod(i * SPACING - travel, LOOP); // 0 = right behind the camera, LOOP = far end
p.position.z = NEAR - m;
const near = THREE.MathUtils.smoothstep(m, 0, SPACING * 1.5); // fade out as a plane passes the camera
p.material.opacity = near;
}
group.rotation.z = travel * 0.012; // the spiral slowly screws forward as you travel
}
function updateCounter() {
const idx = mod(Math.floor(travel / SPACING + 2), COUNT);
if (idx === currentIndex) return;
currentIndex = idx;
indexEl.textContent = String(idx + 1).padStart(2, '0');
const [name, tag] = WORKS[idx];
if (reduced) { nameEl.textContent = name; tagEl.textContent = tag; return; }
gsap.fromTo(nameEl, { yPercent: 50, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out', overwrite: true, onStart: () => { nameEl.textContent = name; tagEl.textContent = tag; } });
}
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = ((e.clientY - r.top) / r.height) * 2 - 1;
};
section.addEventListener('pointermove', onPointer);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
gsap.from('.tunnel__title', { y: 30, opacity: 0, duration: 1.2, ease: 'power3.out', delay: 0.2 });
gsap.from(['.tunnel__hud', '.tunnel__hint'], { y: 16, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.5, stagger: 0.1 });
});
// ---------------------------------------------------------------- build: read knobs, apply to the scene, (re)create Observer + matchMedia
let ctx, observer;
function build() {
observer?.kill();
ctx?.revert();
// knobs → loop geometry / speed model / fog
SPACING = K('spacing');
RADIUS = K('radius');
LOOP = COUNT * SPACING;
NEAR = SPACING * 1.2;
INPUT_GAIN = K('inputGain');
parallaxKnob = !!K('parallax');
baseSpeed = reduced ? 0 : K('baseSpeed');
targetSpeed = reduced ? 0 : baseSpeed;
scene.fog.far = K('fogFar');
travel = mod(travel, LOOP);
planes.forEach(placePlane);
ctx = gsap.context(() => {
observer = Observer.create({
target: section,
type: 'wheel,touch,pointer',
preventDefault: true,
dragMinimum: 3,
onChangeY(self) {
const dir = self.event.type === 'wheel' ? 1 : -1; // drag/swipe up = travel forward (natural scroll)
targetSpeed = gsap.utils.clamp(-MAX_SPEED, MAX_SPEED, targetSpeed + dir * self.deltaY * INPUT_GAIN);
if (reduced) { travel += dir * self.deltaY * 0.02; layout(); updateCounter(); needsRender = true; }
},
});
const mm = gsap.matchMedia();
mm.add({ mobile: '(max-width: 767px)', touch: '(hover: none)' }, (c) => {
const { mobile, touch } = c.conditions;
camera.fov = mobile ? 72 : 58; camera.updateProjectionMatrix();
parallaxOn = !touch && !reduced;
needsRender = true;
return () => { camera.fov = 58; camera.updateProjectionMatrix(); parallaxOn = !reduced; };
});
});
currentIndex = -1;
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
// ---------------------------------------------------------------- create planes once fonts are ready (so baked titles don't fall back)
async function createPlanes() {
try {
await document.fonts.ready;
await Promise.all([document.fonts.load('300 118px Fraunces'), document.fonts.load('500 26px "Inter Tight"'), document.fonts.load('400 17px "JetBrains Mono"')]);
} catch { /* fallbacks are fine */ }
for (let i = 0; i < COUNT; i++) {
const tex = makeTexture(i);
textures.push(tex);
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, fog: true, toneMapped: false, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, mat);
placePlane(mesh, i);
group.add(mesh);
planes.push(mesh);
}
layout();
updateCounter();
needsRender = true;
if (reduced) render();
}
build();
createPlanes();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function render() { renderer.render(scene, camera); needsRender = false; }
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speed += (targetSpeed - speed) * SPEED_LERP;
targetSpeed += (baseSpeed - targetSpeed) * RETURN_LERP;
travel += speed * dt;
if (travel > LOOP * 1e3 || travel < -LOOP * 1e3) travel = mod(travel, LOOP); // keep the float small on very long sessions
const par = parallaxOn && parallaxKnob;
const px = par ? pointer.x : 0, py = par ? pointer.y : 0;
camera.position.x += (px * 0.45 - camera.position.x) * PARALLAX_LERP;
camera.position.y += (-py * 0.3 - camera.position.y) * PARALLAX_LERP;
camera.rotation.y += (-px * 0.07 - camera.rotation.y) * PARALLAX_LERP;
camera.rotation.x += (-py * 0.045 - camera.rotation.x) * PARALLAX_LERP;
camera.rotation.z += (speed * 0.004 - camera.rotation.z) * PARALLAX_LERP; // a hint of roll with velocity
layout();
updateCounter();
render();
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) render();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
start();
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: render on demand only (input re-renders inside onChangeY)
if (reduced) {
const onDemand = () => { if (needsRender) render(); };
section.addEventListener('pointerup', onDemand);
}
// Playground: re-read knobs and rebuild the input/loop parameters (renderer + textures are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
observer?.kill();
ctx?.revert();
introCtx.revert();
ro.disconnect();
themeObserver.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onPointer);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
textures.forEach((t) => t.dispose());
planes.forEach((p) => p.material.dispose());
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});