Browse products

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.

Loading
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.

prompt.md · Camera Fly-through on Scroll
### 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>