Wavy Image Plane on Scroll

Four figures in a normal scrolling page are mirrored by WebGL planes on one fixed canvas, matched to their DOM rectangles every frame; the planes bow and ripple with scroll velocity in the vertex shader and zoom slightly on hover.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1768 words of exact values — rewritten live as you tune the Playground.

prompt.md · Wavy Image Plane on Scroll
### EFFECT: Wavy Image Plane on Scroll
**What it does (visual):** A normal, scrollable editorial page: a full-height intro with a large light serif headline ("Pictures that bend with your speed."), then four `<figure>` plates laid out on a 12-column grid with alternating offsets (a 4:3 landscape left, a 3:4 portrait right, a square left-of-centre, a wide 16:9), each with a caption row (index, serif title, mono meta), then a short outro. The pictures themselves are not `<img>` elements — a single fixed, transparent WebGL canvas sits behind the page and draws one plane per figure, positioned and sized to match the figure's rectangle every frame, so they scroll exactly with the page. While the user scrolls, every plane bows in the direction of travel: its centre column lags behind its edges in a smooth sine arc (up to 0.08 of the plane's height at full speed, as a fraction), the sides pinch very slightly (0.02 of the width), and — when `true` is on — the image's red and blue channels separate by a hair vertically. Full bend is reached when the page moves 0.1 of the viewport per frame. When scrolling stops the planes relax back to flat (fast to react, settling by 0.05 of the remaining bend per frame — roughly a second at the default). Hovering a plate zooms its image in by 0.08 (as a fraction) and brightens it 6%, easing in and out. The pictures are procedurally painted "photographs": a diagonal duotone base, two large screen-blended colour glows, a small bright sun with a halo on a thin horizon line, film grain and a soft vignette — one palette per plate (dusk, moss, ember, tide).

**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`): `WebGLRenderer({ alpha: true })`, `PerspectiveCamera`, `PlaneGeometry(1, 1, 32, 32)`, `ShaderMaterial`, `CanvasTexture`.
- Canvas 2D for the procedural images. No GSAP, no smooth-scroll library required (works with Lenis too — velocity is read from `window.scrollY`).
- npm: `npm i three`. CDN: `<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js"}}</script>`.

**HTML structure:**
```html
<div class="wavy">
  <canvas class="wavy__gl" aria-hidden="true"></canvas>
  <section class="wavy__intro">
    <p class="wavy__label">Scroll ↓ · WebGL images</p>
    <h1 class="wavy__title">Pictures that bend with your speed.</h1>
    <p class="wavy__lede">…</p>
  </section>
  <section class="wavy__feed">
    <figure class="wavy__fig wavy__fig--a" data-palette="dusk">
      <div class="wavy__img"></div>
      <figcaption><span class="wavy__num">01</span><span class="wavy__name">Dusk over Vestfjord</span><span class="wavy__meta">Halden Atlas · Plate I</span></figcaption>
    </figure>
    <!-- --b moss (3:4), --c ember (1:1), --d tide (16:9) -->
  </section>
  <section class="wavy__outro"><p class="wavy__label">End of plates</p><p class="wavy__outro-text">…</p></section>
</div>
```
`.wavy__img` is an empty, transparent box that only provides the rectangle; in production replace it with an `<img>` whose texture you load (keep the box for sizing).

**CSS requirements:**
- `.wavy__gl`: `position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; display: block;` — all `.wavy > section { position: relative; z-index: 1 }` so captions sit above the canvas.
- `.wavy__intro`: `min-height: 100vh; display: grid; align-content: center; padding: 6vw;` — `.wavy__outro` same with `min-height: 70vh`.
- `.wavy__title`: display serif, `font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); line-height: .92; letter-spacing: -0.04em; max-width: 12ch;` — `.wavy__lede`: `max-width: 52ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2.5rem;`
- `.wavy__feed`: `display: grid; grid-template-columns: repeat(12, 1fr); gap: 0 2vw; row-gap: 18vh; padding: 0 6vw 10vh;` — `--a { grid-column: 1 / span 7 }`, `--b { grid-column: 6 / span 7; margin-top: -6vh }`, `--c { grid-column: 2 / span 5 }`, `--d { grid-column: 4 / span 9 }`.
- `.wavy__img`: `width: 100%; aspect-ratio: 4/3; border-radius: 6px; background: transparent;` (`3/4`, `1/1`, `16/9` for b/c/d).
- `figcaption`: `display: grid; grid-template-columns: 3rem 1fr auto; align-items: baseline; gap: 1rem; margin-top: 1rem;` — num/meta mono 11px uppercase muted, name serif `clamp(1.25rem, 2vw, 1.75rem)` weight 300.
- `.wavy__img canvas { width: 100%; height: 100%; object-fit: cover; border-radius: 6px }` for the fallback.
- `@media (prefers-reduced-motion: reduce) { .wavy__gl { display: none } }`.

**Animation spec (exact values):**
Constants: `AMPLITUDE = 0.08`, `CURVE_X = 0.02`, `VELOCITY_SCALE = 0.1`, `ATTACK = 0.25`, `RELEASE = 0.05`, `HOVER_LERP = 0.08`, `HOVER_ZOOM = 0.08`, `CHROMA_SPLIT = true`, `SEGMENTS = 32`.
1. **Pixel-perfect camera**: `camera = PerspectiveCamera(45, w/h, 10, 2000)`, `camera.position.z = 600`; on every resize `camera.fov = 2 · atan((innerHeight / 2) / 600) · 180/π` so 1 world unit = 1 CSS pixel at z = 0. `renderer.setSize(innerWidth, innerHeight, false)` (the canvas is CSS-sized), `setPixelRatio(min(devicePixelRatio, 2))` (1.5 on mobile, halved in preview), `setClearColor(0x000000, 0)`.
2. **Planes**: one shared `PlaneGeometry(1, 1, 32, 32)`; per figure a `ShaderMaterial` with uniforms `uTexture`, `uPlaneSize (vec2)`, `uImageSize (vec2)`, `uVelocity 0`, `uTime`, `uHover 0`, `uAmplitude 0.08`, `uCurveX 0.02`, `uHoverZoom 0.08`, `uChroma` (`1.0` when `true` is true, else `0.0`).
   Vertex shader:
   ```glsl
   uniform float uVelocity, uTime, uAmplitude, uCurveX;
   varying vec2 vUv;
   void main() {
     vUv = uv;
     vec3 pos = position;                                                   // -0.5..0.5, mesh.scale = (w, h, 1)
     float bend = sin((pos.x + 0.5) * 3.14159265) * uVelocity * uAmplitude; // centre column lags the edges
     pos.y += bend;
     pos.x += sin((pos.y + 0.5) * 3.14159265) * uVelocity * uCurveX;       // slight pinch along x
     gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
   }
   ```
   Fragment shader (cover-fit + hover zoom + velocity chroma split):
   ```glsl
   uniform sampler2D uTexture; uniform vec2 uPlaneSize, uImageSize; uniform float uHover, uHoverZoom, uVelocity, uChroma;
   varying vec2 vUv;
   void main() {
     float planeAspect = uPlaneSize.x / uPlaneSize.y;
     float imageAspect = uImageSize.x / uImageSize.y;
     vec2 ratio = vec2(min(planeAspect / imageAspect, 1.0), min(imageAspect / planeAspect, 1.0));
     float zoom = 1.0 - uHover * uHoverZoom;                 // uHoverZoom = 0.08
     vec2 uv = (vUv - 0.5) * ratio * zoom + 0.5;
     float shift = uVelocity * 0.006 * uChroma;              // uChroma = 0 disables the split
     float r = texture2D(uTexture, uv + vec2(0.0, shift)).r;
     float g = texture2D(uTexture, uv).g;
     float b = texture2D(uTexture, uv - vec2(0.0, shift)).b;
     vec3 col = vec3(r, g, b) * (1.0 + uHover * 0.06);
     gl_FragColor = vec4(col, 1.0);
   }
   ```
3. **DOM sync, every frame** for each plane: `r = box.getBoundingClientRect()`; `mesh.scale.set(r.width, r.height, 1)`; `mesh.position.set(r.left + r.width/2 − innerWidth/2, −(r.top + r.height/2) + innerHeight/2, 0)`; `mesh.visible = r.bottom > −r.height && r.top < innerHeight + r.height`; `uPlaneSize = (r.width, r.height)`.
4. **Velocity**: `raw = (scrollY − lastY) / (innerHeight · 0.1)` (unclamped), `velocity += (raw − velocity) · (|raw| > |velocity| ? 0.25 : 0.05)`, then `uVelocity = clamp(velocity, −1, 1)`. The asymmetric smoothing makes the bend snap in and ease out; a programmatic jump carries proportional energy and settles in ≈1s.
5. **Hover**: `pointerenter` → `hoverTarget = 1`, `pointerleave` → `0`; per frame `hover += (hoverTarget − hover) · 0.08` → `uHover`.
6. **Textures**: for each figure paint a canvas at `w = min(1600, rect.width · min(dpr, 1.5))`, `h = w · rect.height / rect.width`:
   - base: `linearGradient(0,0 → 0.3w,h)` from `base[0]` to `base[1]`;
   - `globalCompositeOperation = 'screen'`: radial glow at (0.28w, 0.34h) radius `0.6·max(w,h)` from `glow[0]` @ 0.9 → 0.33 @ 45% → transparent; second glow at (0.8w, 0.8h) radius `0.55·max(w,h)` from `glow[1]` @ 0.8 → 0.2 @ 50% → transparent;
   - `'lighter'`: halo at (0.64w, 0.4h) radius `0.3·min(w,h)` (`line` colour @ 0.6 → 0.15 @ 30% → 0), then `'source-over'` a solid disc radius `0.045·min(w,h)` in `line` @ 0.95, a bezier horizon stroke at `y ≈ 0.64h` (`line` @ 0.4, width `max(1, w/900)`) and a fainter one at `0.72h` (@ 0.16);
   - grain: a 160×160 random grey (110–200) pattern, `'overlay'` at alpha 0.38; vignette: `'multiply'` radial white → `#8a8a8a` from `0.35·min` to `0.8·max`.
   - Palettes: `dusk { base #1b2440/#3a2a4a, glow #ff9a6c/#6c7bff, line #f2c8a2 }`, `moss { #15291f/#0c1510, #a7c957/#3a8f6a, #d9f2a6 }`, `ember { #3a1a16/#120c0a, #ff7a45/#ffd166, #ffe8c2 }`, `tide { #0e1f2e/#07111a, #43b7c9/#D4FF3A, #cfe9f2 }`.
   - Set `texture.image = canvas; texture.needsUpdate = true; uImageSize = (w, h)`; `minFilter = LinearFilter; generateMipmaps = false`. Repaint on resize.

**Interaction:** Scrolling (wheel, trackpad, keyboard, touch, scrollbar drag) is the only driver of the bend — it reads `window.scrollY`, so it works with native scroll and with Lenis/locomotive-style smooth scroll alike. Hovering a `<figure>` zooms/brightens its plane (0.08 lerp per frame in and out). No clicks.

**Responsive behavior:** Under 768px all four figures span the full width (`grid-column: 1 / -1`, `margin-top: 0`, row gap 10vh, the portrait plate becomes 4:5); the caption collapses to two columns with the meta on the second row. DPR is capped at 1.5 on mobile. Touch scrolling produces larger per-frame deltas, so the same `VELOCITY_SCALE` gives a slightly stronger bend on phones — desirable. On `resize`: renderer size, camera aspect + fov, and all textures are recomputed.

**Accessibility:** With `prefers-reduced-motion: reduce` (or if `WebGLRenderer` throws) the canvas is hidden and each figure receives its painted canvas as a normal in-flow element (`.wavy__img canvas`, `object-fit: cover`) — the same pictures, no motion. The WebGL canvas is `aria-hidden="true"`; figures use real `<figure>/<figcaption>` so the list of plates is readable. Captions are DOM text with full contrast.

**Performance rules:** One renderer, one shared geometry (32×32 = 2,178 vertices per plane), four draw calls; planes outside the viewport are set `visible = false`. Rects are read inside the rAF loop (four `getBoundingClientRect` calls per frame — cheap), never on `scroll` events, so there is no one-frame lag. The loop stops on the `ml:pause` window event and when the document is hidden, resumes on `ml:resume`/visible (re-seeding `lastY` so there is no velocity spike). DPR capped at `Math.min(devicePixelRatio, 2)`. On cleanup: cancel the rAF, remove all listeners, dispose each material and texture, the geometry and the renderer.

**Customization knobs:**
- `AMPLITUDE` (0.08) — maximum bend as a fraction of plane height; 0.12 is dramatic, 0.04 is a whisper.
- `CURVE_X` (0.02) — side pinch; set 0 for a pure vertical bow.
- `VELOCITY_SCALE` (0.1) — how fast you must scroll to reach full bend (fraction of viewport per frame).
- `ATTACK` / `RELEASE` (0.25 / 0.05) — reaction and settle speed.
- `HOVER_ZOOM` (0.08) and the brighten factor (0.06).
- `CHROMA_SPLIT` (true) — the R/B split (`0.006` at full velocity); `SEGMENTS` (32) — 16 is fine for small planes.
- Palettes — any `{ base, glow, line }` set; or swap `paint()` for an `<img>`-backed `THREE.Texture`.

**Acceptance criteria:**
- [ ] Each WebGL plane sits exactly on its figure's rectangle at every scroll position and viewport size (no drift, no lag).
- [ ] Scrolling bows the planes in the travel direction up to 0.08 × their height; stopping relaxes them smoothly (release 0.05).
- [ ] Hovering a plate zooms its image by 0.08 (cover-fit preserved, no stretching) and brightens it slightly.
- [ ] Pictures are procedurally generated (no network images), each with glow, sun, horizon, grain and vignette.
- [ ] Reduced motion / no WebGL shows the same pictures as static canvases inside the figures.
- [ ] No console errors; loop pauses when hidden; everything disposed on unmount.

Source

Full source code

<div class="wavy">
  <canvas class="wavy__gl" aria-hidden="true"></canvas>

  <section class="wavy__intro">
    <p class="wavy__label">Scroll ↓ · WebGL images</p>
    <h1 class="wavy__title">Pictures that bend with your speed.</h1>
    <p class="wavy__lede">Four ordinary figures in the document. Behind them, one fixed canvas draws a plane for each — pinned to its rectangle every frame — that flexes with scroll velocity and settles when you stop.</p>
  </section>

  <section class="wavy__feed">
    <figure class="wavy__fig wavy__fig--a" data-palette="dusk">
      <div class="wavy__img"></div>
      <figcaption><span class="wavy__num">01</span><span class="wavy__name">Dusk over Vestfjord</span><span class="wavy__meta">Halden Atlas · Plate I</span></figcaption>
    </figure>
    <figure class="wavy__fig wavy__fig--b" data-palette="moss">
      <div class="wavy__img"></div>
      <figcaption><span class="wavy__num">02</span><span class="wavy__name">Moss, after rain</span><span class="wavy__meta">Halden Atlas · Plate II</span></figcaption>
    </figure>
    <figure class="wavy__fig wavy__fig--c" data-palette="ember">
      <div class="wavy__img"></div>
      <figcaption><span class="wavy__num">03</span><span class="wavy__name">Ember line</span><span class="wavy__meta">Halden Atlas · Plate III</span></figcaption>
    </figure>
    <figure class="wavy__fig wavy__fig--d" data-palette="tide">
      <div class="wavy__img"></div>
      <figcaption><span class="wavy__num">04</span><span class="wavy__name">Tide chart, midnight</span><span class="wavy__meta">Halden Atlas · Plate IV</span></figcaption>
    </figure>
  </section>

  <section class="wavy__outro">
    <p class="wavy__label">End of plates</p>
    <p class="wavy__outro-text">Stop scrolling and the planes relax back to flat. Hover a plate to zoom its image a little.</p>
  </section>
</div>