Camera Fly-through on Scroll
A pinned, scrubbed scroll flies a Three.js camera along a Catmull-Rom spline through floating prints, accent rings and drifting particles, aiming a little ahead of itself; fog keeps the far work soft and DOM captions land at four waypoints.
0%pin trigger range
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 2152 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Camera Fly-through on Scroll
**What it does (visual):** After a full-height intro ("Fly the year"), the user reaches a full-viewport stage that locks in place (pinned). It is a dark 3D space: thin floating prints with hairline accent edges, four accent-coloured rings standing across the route, and a slow twinkle of dust, all softened by fog that matches the page background so distant work is only a suggestion. As the user scrolls, the stage stays pinned while a camera flies forward along a smooth Catmull-Rom spline (a lazy S, ~120 world units long) at 62° field of view, always aiming 0.04 of the path ahead of itself so it banks into the bends before it reaches them; prints slide past on both sides, the rings grow and the camera passes through their centres, dust drifts by. Four captions land at waypoints — "Leave the ground" at 3 %, "Through the work" at 30 % (right-aligned), "Fog keeps the far work soft" at 56 %, "Where the path ends" at 84 % — each rising 30px and fading in over 5 % of the flight, holding for 17 %, then lifting 30px and fading out; the last one stays. A mono HUD counts `Waypoint 01 / 04` and a 1px vertical rail on the right fills with the accent (#d4ff3a). The path ends in front of a fanned "destination" cluster of prints behind a large ring, so the last frame is a composed shot. Scrolling back flies the camera in reverse (it is scrubbed with 1.2s of smoothing). Fog can be switched off (true). The whole flight is 550% of the viewport height of scrolling.
**Tech stack:**
- GSAP 3.13+ with ScrollTrigger; Three.js r160+ (`WebGLRenderer`, `CatmullRomCurve3`, `Fog`, `MeshBasicMaterial`, `LineSegments`, `Points` + `ShaderMaterial`, `CanvasTexture`).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>` + import map `{"imports":{"three":"https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"}}`.
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import * as THREE from "three"; gsap.registerPlugin(ScrollTrigger);`
- Optional Lenis: `lenis.on('scroll', ScrollTrigger.update); gsap.ticker.add(t => lenis.raf(t * 1000)); gsap.ticker.lagSmoothing(0)`.
- Fonts: a variable display serif for the caption titles (demo: Fraunces 300), a mono for the HUD.
**HTML structure:**
```html
<section class="fly-intro">
<p class="fly-intro__label">Scroll · Camera fly-through</p>
<h1 class="fly-intro__title">Fly the year</h1>
<p class="fly-intro__copy">The scrollbar is the throttle.</p>
</section>
<section class="fly">
<div class="fly__stage"> <!-- the pinned element -->
<canvas class="fly__gl" aria-hidden="true"></canvas>
<div class="fly__hud" aria-hidden="true">
<span class="fly__counter">Waypoint <span class="fly__index">01</span> / 04</span>
<span class="fly__tag">Studio Meridian · annual flight</span>
</div>
<div class="fly__caps">
<article class="fly__cap fly__cap--1"><p class="fly__cap-label">01 · Departure</p><h2 class="fly__cap-title">Leave the ground</h2><p class="fly__cap-copy">The camera lifts off the page and follows a spline through the studio's year.</p></article>
<article class="fly__cap fly__cap--2 fly__cap--right"><p class="fly__cap-label">02 · First ring</p><h2 class="fly__cap-title">Through the work</h2><p class="fly__cap-copy">Every panel is a project we shipped; every ring a quarter closed.</p></article>
<article class="fly__cap fly__cap--3"><p class="fly__cap-label">03 · The quiet stretch</p><h2 class="fly__cap-title">Fog keeps the far work soft</h2><p class="fly__cap-copy">Nothing is revealed until you have scrolled to it.</p></article>
<article class="fly__cap fly__cap--4 fly__cap--right"><p class="fly__cap-label">04 · Arrival</p><h2 class="fly__cap-title">Where the path ends</h2><p class="fly__cap-copy">The last ring frames what comes next.</p></article>
</div>
<div class="fly__rail" aria-hidden="true"><span class="fly__rail-fill"></span></div>
</div>
</section>
<section class="fly-outro"><p class="fly-outro__text">Touchdown. The pin releases and the page continues normally.</p></section>
```
**CSS requirements:**
- `.fly-intro`, `.fly-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;` `.fly-intro__title`: display serif `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9;`
- `.fly__stage`: `position: relative; height: 100vh; overflow: hidden; background: var(--bg, #0a0a0a);` — the pinned element. `.fly__gl`: `position: absolute; inset: 0; width: 100%; height: 100%;` (bitmap size via `renderer.setSize(w, h, false)`).
- `.fly__stage::before`: `content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 1; background: radial-gradient(70% 60% at 50% 50%, transparent 50%, color-mix(in srgb, var(--bg) 60%, transparent) 100%);` — vignette.
- `.fly__hud`: `position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; z-index: 2;` mono 11px uppercase `letter-spacing: .12em` muted; `.fly__counter { color: var(--text); font-variant-numeric: tabular-nums; }`
- `.fly__caps`: `position: absolute; inset: 0; z-index: 2; pointer-events: none;` `.fly__cap`: `position: absolute; left: 6vw; bottom: clamp(2.5rem, 10vh, 7rem); max-width: min(34rem, 80vw); opacity: 0; visibility: hidden; will-change: transform, opacity;` `.fly__cap--right { left: auto; right: 6vw; text-align: right; }`
- `.fly__cap-label`: mono 11px uppercase, colour `var(--accent, #d4ff3a)`, `margin-bottom: 1rem`. `.fly__cap-title`: display serif `font-weight: 300; font-size: clamp(2.2rem, 5.5vw, 5.25rem); line-height: .95; letter-spacing: -0.04em;` `.fly__cap-copy`: muted, `max-width: 36ch; line-height: 1.5; margin-top: 1rem`.
- `.fly__rail`: `position: absolute; right: 6vw; top: 50%; translate: 0 -50%; width: 1px; height: 32vh; background: color-mix(in srgb, var(--text) 14%, transparent); z-index: 2;` `.fly__rail-fill { position: absolute; inset: 0; background: var(--accent, #d4ff3a); transform-origin: top; transform: scaleY(0); }`
- Under 768px: hide `.fly__tag` and `.fly__rail`; all captions `left: 6vw; right: 6vw; text-align: left`. Under `prefers-reduced-motion: reduce` hide `.fly__rail`.
**Animation spec (exact values):**
1. Constants: `WAYPOINTS = [0.03, 0.3, 0.56, 0.84]`, `CAP_HOLD = 0.17`, `CAP_FADE = 0.05`. Knobs: `PATH_LENGTH = '+=550%'`, `SCRUB = 1.2`, `FOV = 62`, `LOOK_AHEAD = 0.04`, `FOG = true`, `ACCENT = '#d4ff3a'`.
2. Renderer: `new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' })`, `setPixelRatio(Math.min(devicePixelRatio, 2))` (1.5 under 768px; halve again for low-power previews), `setClearColor(0x000000, 0)`; `PerspectiveCamera(62, aspect, 0.1, 220)`. Wrap the constructor in `try/catch`; without WebGL show a painted still and the last caption.
3. Fog: `scene.fog = new THREE.Fog(bg, 8, 48)` where `bg` is the page `--bg` colour (re-read on theme change); `scene.fog = null` when true is false. Every material has `fog: true` (default for built-ins); the particle shader reproduces the same `smoothstep(8, 48, depth)` fade with its own uniforms.
4. Path: `new THREE.CatmullRomCurve3([(0,0,0), (4,1.2,−16), (−5,−1,−34), (2.5,2.2,−52), (−4,0.4,−70), (1.5,−1.2,−88), (0,0.6,−104), (0,0,−116)], false, 'catmullrom', 0.6)`; `frames = curve.computeFrenetFrames(160, false)`; `endPoint = curve.getPointAt(1)`, `endTan = curve.getTangentAt(1)`.
5. Prints (34 on desktop, 22 on phones): for `i`, `t = 0.05 + 0.9·i/count ± 0.01`; position `curve.getPointAt(t) + normal·cos(φ)·r + binormal·sin(φ)·r` with `φ` random, `r = 2.6 + rand·4.5`; `PlaneGeometry(1,1)` scaled to `w × 0.75w` (`w = 1.6 + rand·1.8`), `MeshBasicMaterial({ map, side: DoubleSide })` using one of five 256 × 192 procedural prints (linear gradient base → shadow, screen radial glow, a bezier horizon line, a hairline border; `texture.colorSpace = SRGBColorSpace`), `lookAt(pathPoint + (±1.5, ±1.5, 6))` so they roughly face the oncoming camera; add `LineSegments(EdgesGeometry, LineBasicMaterial({ color: ACCENT, transparent: true, opacity: 0.55 }))` as a child. Destination cluster: seven 2.4 × 1.8 prints at `endPoint + endTan·(14 + |i−3|·1.2) + (sin(a)·7.5, cos(2a)·1.6 − 0.4, 0)`, `a = (i/6 − 0.5)·0.9π`, each `lookAt(endPoint + endTan·4)`.
6. Rings at `t = 0.3, 0.56, 0.84` on the path and one at `endPoint + endTan·9` (scaled 1.6): `TorusGeometry(3.4, 0.028, 6, 128)` in `MeshBasicMaterial({ color: ACCENT, transparent: true, opacity: 0.9 })` plus a halo `TorusGeometry(5.2, 0.012, 4, 128)` at opacity 0.22; each `lookAt(position + tangent)`.
7. Dust: 1800 points (900 on phones) at `t = rand·1.08` (beyond 1 continue along `endTan`), offset in the Frenet plane by `r = 1 + rand^0.6·9`; attributes `aSeed`, `aSize = 0.5 + rand`. `ShaderMaterial({ transparent: true, depthWrite: false, blending: AdditiveBlending })`, `frustumCulled = false`:
```glsl
// vertex
attribute float aSeed; attribute float aSize; uniform float uTime; uniform float uDpr;
varying float vAlpha; varying float vDepth;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
float tw = 0.55 + 0.45 * sin(uTime * 1.4 + aSeed * 6.2831);
vAlpha = tw; vDepth = -mv.z;
gl_PointSize = aSize * 1.8 * uDpr * (0.7 + 0.3 * tw) * (60.0 / max(1.0, -mv.z));
gl_Position = projectionMatrix * mv;
}
// fragment
precision highp float; uniform vec3 uColor; uniform vec2 uFogRange; uniform float uFogOn;
varying float vAlpha; varying float vDepth;
void main() {
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.12, d) * vAlpha * smoothstep(1.5, 4.0, vDepth);
float f = uFogOn * smoothstep(uFogRange.x, uFogRange.y, vDepth);
gl_FragColor = vec4(uColor, a * (1.0 - f));
}
```
`uColor = ACCENT lerped 55 % toward white`, `uFogRange = (8, 48)`, `uFogOn = true ? 1 : 0`.
8. Camera placement every frame: `pos = curve.getPointAt(p)`; `target = p + 0.04 ≤ 1 ? curve.getPointAt(p + 0.04) : endPoint + endTan · (p + 0.04 − 1) · curve.getLength()`; add idle sway `normal · sin(0.6t) · 0.18 + binormal · cos(0.45t) · 0.12`; `camera.lookAt(target)`. rAF loop renders continuously (dust twinkles); stop on `visibilitychange`/pause.
9. Scrubbed timeline (all `ease: 'none'`):
```js
const state = { progress: 0 };
const tl = gsap.timeline({ defaults: { ease: 'none' },
scrollTrigger: { trigger: '.fly__stage', start: 'top top', end: '+=550%', pin: true, pinSpacing: true, scrub: 1.2, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
onUpdate: self => indexEl.textContent = String(Math.max(1, WAYPOINTS.filter(w => self.progress >= w).length)).padStart(2, '0') } });
tl.fromTo(state, { progress: 0 }, { progress: 1, duration: 1 }, 0)
.fromTo('.fly__rail-fill', { scaleY: 0 }, { scaleY: 1, duration: 1 }, 0);
caps.forEach((cap, i) => {
tl.fromTo(cap, { autoAlpha: 0, y: 30 }, { autoAlpha: 1, y: 0, duration: 0.05 }, WAYPOINTS[i]);
if (i < 3) tl.to(cap, { autoAlpha: 0, y: -30, duration: 0.05 }, WAYPOINTS[i] + 0.17); // the arrival caption stays
});
```
Under 768px the same timeline runs with `end: '+=' + Math.round(550 × 0.7) + '%'`.
10. Intro: `gsap.from('.fly-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`. After `document.fonts.ready` → `ScrollTrigger.refresh()`.
**Scroll map:**
| Trigger | Start | End | Scrub | Pin | Animates |
|---|---|---|---|---|---|
| `.fly__stage` | `top top` | `+=550%` (× 0.7 under 768px) | 1.2 | yes (`pinSpacing: true`, `anticipatePin: 1`) | `state.progress` 0 → 1 → camera position/lookAt along the spline |
| `.fly__stage` | `top top` | same range | 1.2 | same trigger | `.fly__rail-fill` scaleY 0 → 1; `.fly__index` text |
| same timeline @ 0.03 / 0.30 / 0.56 / 0.84 | — | +0.05 in, +0.17 hold, +0.05 out | 1.2 | — | `.fly__cap--1…4` autoAlpha 0 → 1 (→ 0), y 30 → 0 (→ −30); cap 4 stays |
**Section heights / pin duration:** intro `100vh`; `.fly__stage` is `100vh` tall and pinned for `+=550%` of viewport height (550 × 0.7 % under 768px) — the camera covers the ~120-unit spline linearly over that distance; outro `100vh`. With reduced motion nothing is pinned and the stage is a normal `100vh` block.
**Common bugs & fixes:**
- Camera spins at the end of the path → `getPointAt(p + lookAhead)` clamps at 1 and collapses the lookAt; extrapolate the target along `endTan` past the end (step 8).
- Objects pop in from nowhere → fog colour must equal the page background and every material must be `fog: true`; the custom particle shader needs its own fog fade with the same near/far.
- Blobs across the lens → fade particle alpha with `smoothstep(1.5, 4.0, depth)` and keep the tube's inner radius ≥ 1.
- Wrong pin start after fonts load → `invalidateOnRefresh: true`, `ScrollTrigger.refresh()` after `document.fonts.ready`.
- Jump when the pin engages → `pinSpacing: true`, `anticipatePin: 1`, no margins on `.fly__stage`.
- Mobile address-bar resize restarts the pin → `ScrollTrigger.config({ ignoreMobileResize: true })`.
- iOS momentum feels like a slideshow → `scrub: 1.2` (not `true`) and a continuous rAF so the smoothing is visible.
- Nested pins → the captions and rail live inside the single pinned `.fly__stage`; never pin the stage inside another pin.
**Interaction:** scroll only (wheel, trackpad, keyboard, touch). Fully reversible. No pointer interaction.
**Responsive behavior:** `gsap.matchMedia()` — `(min-width: 768px)`: the spec above. `(max-width: 767px)`: same pin with `end` × 0.7, 22 prints and 900 dust points, DPR cap 1.5, captions left-aligned full width, rail and tag hidden. Touch keeps scrub.
**Accessibility:** `prefers-reduced-motion: reduce` → no pin, no scrub: the stage is a static `100vh` block rendered once at `progress = 1` (the arrival shot) with the fourth caption visible and the HUD at `04`; the rail is hidden. Captions are `<article>`s with real `<h2>`s so the four stops read as a list; the canvas is `aria-hidden`.
**Performance rules:** DPR `Math.min(devicePixelRatio, 2)`; one shared plane geometry + edges geometry, five print materials, two torus geometries, one particle buffer; everything `fog: true`. Only uniforms, camera transforms, `transform` (captions y, rail scaleY) and `opacity`/`visibility` are animated. rAF stops when hidden or paused; `renderer.dispose()` plus every geometry/material/texture is disposed and `ctx.revert()` unpins on unmount. `ScrollTrigger.config({ ignoreMobileResize: true })` is set once.
**Customization knobs:** `pathLength` (550% pinned), `scrub` (1.2s), `fov` (62°), `lookAhead` (0.04 — smaller aims closer, sharper turns), `fog` (true), `accent` (#d4ff3a — rings, edges, dust tint, labels, rail), the spline control points and `WAYPOINTS`.
**Acceptance criteria:**
- [ ] Stage pins at `top top` for `550%`; the camera starts at the spline origin and ends in front of the destination ring.
- [ ] The camera visibly anticipates bends (aims ahead) and passes through the centre of each ring.
- [ ] Captions land at 3 / 30 / 56 / 84 % and the HUD counter matches; the last caption stays.
- [ ] Fog fades distant prints into the page colour (no grey veil); toggling fog off reveals the whole route.
- [ ] Reverses cleanly; no jump at pin start/end; works at 375px and 1440px; reduced motion shows the arrival shot without a pin.
- [ ] No console errors; `ctx.revert()` removes the pin spacer; renderer and resources are disposed on unmount. Source
Full source code
<section class="fly-intro">
<p class="fly-intro__label">Scroll · Camera fly-through</p>
<h1 class="fly-intro__title">Fly the year</h1>
<p class="fly-intro__copy">The scrollbar is the throttle. A camera follows a spline through twelve months of work, through four rings, and lands where the next one starts.</p>
</section>
<section class="fly">
<div class="fly__stage">
<canvas class="fly__gl" aria-hidden="true"></canvas>
<div class="fly__fallback" aria-hidden="true"></div>
<div class="fly__hud" aria-hidden="true">
<span class="fly__counter">Waypoint <span class="fly__index">01</span> / 04</span>
<span class="fly__tag">Studio Meridian · annual flight</span>
</div>
<div class="fly__caps">
<article class="fly__cap fly__cap--1">
<p class="fly__cap-label">01 · Departure</p>
<h2 class="fly__cap-title">Leave the ground</h2>
<p class="fly__cap-copy">The camera lifts off the page and follows a spline through the studio's year.</p>
</article>
<article class="fly__cap fly__cap--2 fly__cap--right">
<p class="fly__cap-label">02 · First ring</p>
<h2 class="fly__cap-title">Through the work</h2>
<p class="fly__cap-copy">Every panel is a project we shipped; every ring a quarter closed.</p>
</article>
<article class="fly__cap fly__cap--3">
<p class="fly__cap-label">03 · The quiet stretch</p>
<h2 class="fly__cap-title">Fog keeps the far work soft</h2>
<p class="fly__cap-copy">Nothing is revealed until you have scrolled to it.</p>
</article>
<article class="fly__cap fly__cap--4 fly__cap--right">
<p class="fly__cap-label">04 · Arrival</p>
<h2 class="fly__cap-title">Where the path ends</h2>
<p class="fly__cap-copy">The last ring frames what comes next.</p>
</article>
</div>
<div class="fly__rail" aria-hidden="true"><span class="fly__rail-fill"></span></div>
</div>
</section>
<section class="fly-outro">
<p class="fly-outro__text">Touchdown. The pin releases and the page continues normally.</p>
</section>
.fly-intro, .fly-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.fly-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.fly-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9; }
.fly-intro__copy { margin-top: 2rem; max-width: 44ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.fly-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.fly { position: relative; }
.fly__stage { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.fly__gl { position: absolute; inset: 0; width: 100%; height: 100%; }
.fly__stage::before { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 1;
background: radial-gradient(70% 60% at 50% 50%, transparent 50%, color-mix(in srgb, var(--bg) 60%, transparent) 100%); }
.fly__hud { position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; gap: 1rem; z-index: 2;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.fly__counter { color: var(--text); font-variant-numeric: tabular-nums; }
.fly__tag { text-align: right; }
.fly__caps { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.fly__cap { position: absolute; left: 6vw; bottom: clamp(2.5rem, 10vh, 7rem); max-width: min(34rem, 80vw); opacity: 0; visibility: hidden; will-change: transform, opacity; }
.fly__cap--right { left: auto; right: 6vw; text-align: right; }
.fly__cap-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--knob-accent, var(--accent)); margin-bottom: 1rem; }
.fly__cap-title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.2rem, 5.5vw, 5.25rem); line-height: .95; letter-spacing: -0.04em; }
.fly__cap-copy { margin-top: 1rem; color: var(--muted); max-width: 36ch; font-size: clamp(.95rem, 1.1vw, 1.1rem); line-height: 1.5; }
.fly__cap--right .fly__cap-copy { margin-left: auto; }
.fly__rail { position: absolute; right: 6vw; top: 50%; translate: 0 -50%; width: 1px; height: 32vh; background: color-mix(in srgb, var(--text) 14%, transparent); z-index: 2; }
.fly__rail-fill { position: absolute; inset: 0; background: var(--knob-accent, var(--accent)); transform-origin: top; transform: scaleY(0); }
/* No WebGL: a painted still of the scene */
.fly__fallback { display: none; }
.fly.is-fallback .fly__gl { display: none; }
.fly.is-fallback .fly__fallback { display: block; position: absolute; inset: 0; }
.fly.is-fallback .fly__fallback canvas { width: 100%; height: 100%; object-fit: cover; }
@media (max-width: 767px) {
.fly__tag, .fly__rail { display: none; }
.fly__cap, .fly__cap--right { left: 6vw; right: 6vw; text-align: left; bottom: clamp(3rem, 10vh, 5rem); }
.fly__cap--right .fly__cap-copy { margin-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fly__rail { display: none; }
}
// Camera Fly-through on Scroll — a pinned, scrubbed scroll flies the camera along a Catmull-Rom spline through floating work.
import * as THREE from 'three';
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({ ignoreMobileResize: true });
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { pathLength: 550, scrub: 1.2, fov: 62, lookAhead: 0.04, fog: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const WAYPOINTS = [0.03, 0.3, 0.56, 0.84]; // progress at which each caption lands
const CAP_HOLD = 0.17; // how long (in progress) a caption stays before leaving
const CAP_FADE = 0.05;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
const section = document.querySelector('.fly');
const stage = section.querySelector('.fly__stage');
const canvas = section.querySelector('.fly__gl');
const indexEl = section.querySelector('.fly__index');
const caps = gsap.utils.toArray('.fly__cap');
const state = { progress: reduced ? 1 : 0 };
/* ---------- tokens ---------- */
const cssColor = (name, fallback) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return /^#[0-9a-f]{3,6}$/i.test(v) ? v : fallback;
};
const rand = (() => { let s = 4242; return () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; })(); // deterministic layout
/* ---------- procedural prints for the panels ---------- */
function paintPanel(c1, c2, glow, w = 256, h = 192) {
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
const c = cv.getContext('2d');
const g = c.createLinearGradient(0, 0, w * 0.3, h); g.addColorStop(0, c1); g.addColorStop(1, c2);
c.fillStyle = g; c.fillRect(0, 0, w, h);
c.globalCompositeOperation = 'screen';
const r = c.createRadialGradient(w * 0.35, h * 0.35, 0, w * 0.35, h * 0.35, w * 0.6);
r.addColorStop(0, glow + 'aa'); r.addColorStop(0.5, glow + '22'); r.addColorStop(1, 'transparent');
c.fillStyle = r; c.fillRect(0, 0, w, h);
c.globalCompositeOperation = 'source-over';
c.strokeStyle = 'rgba(255,255,255,.35)'; c.lineWidth = 1;
c.beginPath(); c.moveTo(0, h * 0.68); c.bezierCurveTo(w * 0.3, h * 0.62, w * 0.6, h * 0.74, w, h * 0.66); c.stroke();
c.strokeStyle = 'rgba(255,255,255,.18)'; c.strokeRect(0.5, 0.5, w - 1, h - 1);
return cv;
}
const PRINTS = [['#1d2d4a', '#07090e', '#ffb37a'], ['#13302b', '#06100d', '#9ad6a8'], ['#3b1f16', '#120906', '#ff8a4d'], ['#2d1b3f', '#0d0814', '#c48bff'], ['#102a33', '#050e12', '#5fd7d0']];
/* ---------- fallback (no WebGL): a painted still ---------- */
function fallback() {
section.classList.add('is-fallback');
const cv = paintPanel('#141a26', '#0a0a0a', '#d4ff3a', 1200, 750);
section.querySelector('.fly__fallback').appendChild(cv);
gsap.set(caps[3], { autoAlpha: 1 });
}
/* ---------- WebGL ---------- */
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
let gl = null;
if (!renderer) fallback(); else gl = init(renderer);
function init(renderer) {
const dpr = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(K('fov'), 1, 0.1, 220);
const bg = new THREE.Color(cssColor('--bg', '#0a0a0a'));
const fog = new THREE.Fog(bg, 8, 48);
scene.fog = K('fog') ? fog : null;
const accent = new THREE.Color(K('accent'));
/* path: a lazy S through the dark, ending in front of the destination cluster */
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 0, 0), new THREE.Vector3(4, 1.2, -16), new THREE.Vector3(-5, -1, -34), new THREE.Vector3(2.5, 2.2, -52),
new THREE.Vector3(-4, 0.4, -70), new THREE.Vector3(1.5, -1.2, -88), new THREE.Vector3(0, 0.6, -104), new THREE.Vector3(0, 0, -116),
], false, 'catmullrom', 0.6);
const SAMPLES = 160;
const frames = curve.computeFrenetFrames(SAMPLES, false);
const pathLength = curve.getLength();
const frameAt = (t) => { const i = Math.min(SAMPLES, Math.round(t * SAMPLES)); return { n: frames.normals[i], b: frames.binormals[i], tg: frames.tangents[i] }; };
const disposables = [];
const accentMats = [];
/* panels: thin prints hugging the path */
const panelGeo = new THREE.PlaneGeometry(1, 1); disposables.push(panelGeo);
const edgeGeo = new THREE.EdgesGeometry(panelGeo); disposables.push(edgeGeo);
const printTextures = PRINTS.map(([a, b, g]) => { const t = new THREE.CanvasTexture(paintPanel(a, b, g)); t.colorSpace = THREE.SRGBColorSpace; disposables.push(t); return t; });
const printMats = printTextures.map((map) => { const m = new THREE.MeshBasicMaterial({ map, side: THREE.DoubleSide, fog: true }); disposables.push(m); return m; });
const edgeMat = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: 0.55, fog: true }); disposables.push(edgeMat); accentMats.push(edgeMat);
const panels = new THREE.Group(); scene.add(panels);
const PANEL_COUNT = isMobile ? 22 : 34;
for (let i = 0; i < PANEL_COUNT; i++) {
const t = 0.05 + (i / PANEL_COUNT) * 0.9 + (rand() - 0.5) * 0.02;
const { n, b } = frameAt(t);
const p = curve.getPointAt(t);
const ang = rand() * Math.PI * 2, rad = 2.6 + rand() * 4.5;
const pos = p.clone().addScaledVector(n, Math.cos(ang) * rad).addScaledVector(b, Math.sin(ang) * rad);
const mesh = new THREE.Mesh(panelGeo, printMats[i % printMats.length]);
const w = 1.6 + rand() * 1.8; mesh.scale.set(w, w * 0.75, 1);
mesh.position.copy(pos);
mesh.lookAt(p.clone().add(new THREE.Vector3((rand() - 0.5) * 3, (rand() - 0.5) * 3, 6))); // roughly face the oncoming camera
const edges = new THREE.LineSegments(edgeGeo, edgeMat); mesh.add(edges);
panels.add(mesh);
}
/* destination cluster: a fan of prints facing the end of the path */
const endPoint = curve.getPointAt(1), endTan = curve.getTangentAt(1).normalize();
for (let i = 0; i < 7; i++) {
const a = (i / 6 - 0.5) * Math.PI * 0.9;
const pos = endPoint.clone().addScaledVector(endTan, 14 + Math.abs(i - 3) * 1.2).add(new THREE.Vector3(Math.sin(a) * 7.5, Math.cos(a * 2) * 1.6 - 0.4, 0));
const mesh = new THREE.Mesh(panelGeo, printMats[i % printMats.length]);
mesh.scale.set(2.4, 1.8, 1); mesh.position.copy(pos);
mesh.lookAt(endPoint.clone().addScaledVector(endTan, 4));
mesh.add(new THREE.LineSegments(edgeGeo, edgeMat));
panels.add(mesh);
}
/* rings: milestones the camera flies through */
const ringGeo = new THREE.TorusGeometry(3.4, 0.028, 6, 128); disposables.push(ringGeo);
const haloGeo = new THREE.TorusGeometry(5.2, 0.012, 4, 128); disposables.push(haloGeo);
const ringMat = new THREE.MeshBasicMaterial({ color: accent, transparent: true, opacity: 0.9, fog: true }); disposables.push(ringMat); accentMats.push(ringMat);
const haloMat = new THREE.MeshBasicMaterial({ color: accent, transparent: true, opacity: 0.22, fog: true }); disposables.push(haloMat); accentMats.push(haloMat);
[0.3, 0.56, 0.84, 1].forEach((t, i) => {
const p = curve.getPointAt(t);
const tg = t === 1 ? endTan : curve.getTangentAt(t);
const pos = t === 1 ? p.clone().addScaledVector(endTan, 9) : p;
const ring = new THREE.Mesh(ringGeo, ringMat); ring.position.copy(pos); ring.lookAt(pos.clone().add(tg));
const halo = new THREE.Mesh(haloGeo, haloMat); halo.position.copy(pos); halo.lookAt(pos.clone().add(tg));
if (t === 1) { ring.scale.setScalar(1.6); halo.scale.setScalar(1.6); }
scene.add(ring, halo);
});
/* particles: dust in a tube around the path, twinkling */
const COUNT = isMobile ? 900 : 1800;
const pos = new Float32Array(COUNT * 3), seed = new Float32Array(COUNT), size = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
const t = rand() * 1.08;
const tt = Math.min(1, t);
const { n, b } = frameAt(tt);
const p = curve.getPointAt(tt).addScaledVector(endTan, Math.max(0, t - 1) * pathLength);
const ang = rand() * Math.PI * 2, rad = 1 + Math.pow(rand(), 0.6) * 9;
p.addScaledVector(n, Math.cos(ang) * rad).addScaledVector(b, Math.sin(ang) * rad);
pos.set([p.x, p.y, p.z], i * 3); seed[i] = rand(); size[i] = 0.5 + rand();
}
const dustGeo = new THREE.BufferGeometry(); disposables.push(dustGeo);
dustGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
dustGeo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
dustGeo.setAttribute('aSize', new THREE.BufferAttribute(size, 1));
const dustUniforms = {
uTime: { value: 0 }, uDpr: { value: dpr }, uColor: { value: accent.clone().lerp(new THREE.Color('#ffffff'), 0.55) },
uFogRange: { value: new THREE.Vector2(fog.near, fog.far) }, uFogOn: { value: K('fog') ? 1 : 0 },
};
const dustMat = new THREE.ShaderMaterial({
uniforms: dustUniforms, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: /* glsl */`
attribute float aSeed; attribute float aSize;
uniform float uTime; uniform float uDpr;
varying float vAlpha; varying float vDepth;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
float tw = 0.55 + 0.45 * sin(uTime * 1.4 + aSeed * 6.2831); // twinkle
vAlpha = tw; vDepth = -mv.z;
gl_PointSize = aSize * 1.8 * uDpr * (0.7 + 0.3 * tw) * (60.0 / max(1.0, -mv.z));
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform vec3 uColor; uniform vec2 uFogRange; uniform float uFogOn;
varying float vAlpha; varying float vDepth;
void main() {
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.12, d) * vAlpha * smoothstep(1.5, 4.0, vDepth); // no blobs right in front of the lens
float f = uFogOn * smoothstep(uFogRange.x, uFogRange.y, vDepth); // same range as scene.fog
gl_FragColor = vec4(uColor, a * (1.0 - f));
}`,
});
disposables.push(dustMat);
const dust = new THREE.Points(dustGeo, dustMat); dust.frustumCulled = false; scene.add(dust);
/* camera along the curve, aiming ahead */
const _pos = new THREE.Vector3(), _target = new THREE.Vector3(), _tan = new THREE.Vector3();
function placeCamera(p, t) {
const la = K('lookAhead');
curve.getPointAt(p, _pos);
if (p + la <= 1) curve.getPointAt(p + la, _target);
else { _target.copy(endPoint).addScaledVector(endTan, (p + la - 1) * pathLength); } // extrapolate past the end
curve.getTangentAt(Math.min(1, p), _tan);
const { n, b } = frameAt(Math.min(1, p));
_pos.addScaledVector(n, Math.sin(t * 0.6) * 0.18).addScaledVector(b, Math.cos(t * 0.45) * 0.12); // gentle idle sway
camera.position.copy(_pos);
camera.lookAt(_target);
}
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / Math.max(1, h);
camera.updateProjectionMatrix();
}
resize();
const clock = new THREE.Clock();
let elapsed = 0;
function render() {
placeCamera(state.progress, elapsed);
dustUniforms.uTime.value = elapsed;
renderer.render(scene, camera);
}
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
elapsed += Math.min(clock.getDelta(), 0.05);
render();
}
const start = () => { if (running || document.hidden) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
const onResize = () => { resize(); if (reduced) render(); };
const themeObserver = new MutationObserver(() => { bg.set(cssColor('--bg', '#0a0a0a')); fog.color.copy(bg); if (reduced) render(); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', onResize);
if (reduced) render(); else start();
return {
render,
setKnobs() {
camera.fov = K('fov'); camera.updateProjectionMatrix();
scene.fog = K('fog') ? fog : null; dustUniforms.uFogOn.value = K('fog') ? 1 : 0;
accent.set(K('accent'));
accentMats.forEach((m) => m.color.copy(accent));
dustUniforms.uColor.value.copy(accent).lerp(new THREE.Color('#ffffff'), 0.55);
if (reduced) render();
},
dispose() {
stop();
themeObserver.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', onResize);
disposables.forEach((d) => d.dispose());
renderer.dispose();
},
};
}
/* ---------- scroll: pin the stage, scrub the flight 0 → 1, land captions at waypoints ---------- */
let ctx, built = false;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
gl?.setKnobs();
const scrub = K('scrub') === 0 ? true : K('scrub');
const pathLength = K('pathLength');
ctx = gsap.context(() => {
if (!built) gsap.from('.fly-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) { // no pin, no scrub: the final frame with the arrival caption
state.progress = 1;
gsap.set(caps, { autoAlpha: 0 }); gsap.set(caps[3], { autoAlpha: 1, y: 0 });
indexEl.textContent = '04';
gl?.render();
return;
}
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (c) => {
const end = '+=' + Math.round(pathLength * (c.conditions.mobile ? 0.7 : 1)) + '%';
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: stage, start: 'top top', end, pin: true, pinSpacing: true, scrub, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
onUpdate: (self) => { indexEl.textContent = String(Math.max(1, WAYPOINTS.filter((w) => self.progress >= w).length)).padStart(2, '0'); },
},
});
tl.fromTo(state, { progress: 0 }, { progress: 1, duration: 1 }, 0)
.fromTo('.fly__rail-fill', { scaleY: 0 }, { scaleY: 1, duration: 1 }, 0);
caps.forEach((cap, i) => {
const wp = WAYPOINTS[i];
tl.fromTo(cap, { autoAlpha: 0, y: 30 }, { autoAlpha: 1, y: 0, duration: CAP_FADE }, wp);
if (i < caps.length - 1) tl.to(cap, { autoAlpha: 0, y: -30, duration: CAP_FADE }, wp + CAP_HOLD); // the arrival caption stays
});
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
built = true;
}
build();
// Playground: rebuild (ctx.revert() unpins and removes the spacer) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
gl?.dispose();
window.removeEventListener('ml:knobs', build);
});