Liquid Hover Distortion (WebGL)

A Three.js plane shows a procedurally painted image that warps like liquid under the pointer (noise UV displacement weighted by distance and speed, with a velocity-driven RGB split); hovering a swatch melts it into a second image.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 2132 words of exact values — targets, eases, durations, triggers.

prompt.md · Liquid Hover Distortion (WebGL)
### EFFECT: Liquid Hover Distortion (WebGL)
**What it does (visual):** A 16:10 frame in the middle of a dark page shows a painted landscape — a deep indigo sky with a warm orange sun, a violet haze low on the left and a thin bright horizon line, with fine film grain over everything (the image is generated on a canvas, no file). At rest the image breathes almost imperceptibly around the centre. When the pointer moves across the frame, the image bends like the surface of water: a soft, roughly 55%-wide zone around the pointer displaces, following the pointer with a slight lag (the distortion centre eases toward the real pointer at 8% per frame), and the faster the pointer moves the deeper the warp — a slow drift barely ripples it, a fast swipe tears the gradient into liquid folds and smears the red and blue channels apart along the direction of travel. When the pointer stops, the warp calms over roughly a second. Below the frame are two pill-shaped swatches ("01 Dusk Harbour", "02 Verdigris"); hovering (or tapping) the second one melts the image into a teal/lime composition: instead of a crossfade, the new image eats through the old along a noisy liquid edge over 1.2s while both images wobble, then settles perfectly still. Hovering the first swatch melts back. A mono caption in the frame's corner updates with the image name and "02 / 02".

**Tech stack:**
- Three.js r160+ (`WebGLRenderer`, `OrthographicCamera`, `PlaneGeometry`, `ShaderMaterial`, `CanvasTexture`), GSAP 3.13+ for the uniform tweens, Canvas 2D for the artwork.
- npm: `npm i three gsap` → `import * as THREE from 'three'; import gsap from 'gsap';`
- CDN: `<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js"}}</script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.

**HTML structure:**
```html
<section class="lhd">
  <header class="lhd__head">
    <div><p class="lhd__eyebrow">WebGL · ShaderMaterial</p><h1 class="lhd__title">Surface tension.</h1></div>
    <p class="lhd__hint">Move across the image · hover a swatch to change it</p>
  </header>
  <div class="lhd__frame">
    <canvas class="lhd__canvas" role="img" aria-label="Interactive liquid distortion of a painted landscape"></canvas>
    <p class="lhd__caption"><span class="lhd__caption-name">Dusk Harbour</span><span class="lhd__caption-index">01 / 02</span></p>
  </div>
  <nav class="lhd__swatches" aria-label="Choose an image">
    <button class="lhd__swatch is-active" data-index="0" aria-pressed="true"><canvas class="lhd__swatch-art" width="240" height="150"></canvas><span class="lhd__swatch-text"><span class="lhd__swatch-num">01</span><span class="lhd__swatch-name">Dusk Harbour</span></span></button>
    <button class="lhd__swatch" data-index="1" aria-pressed="false"><canvas class="lhd__swatch-art" width="240" height="150"></canvas><span class="lhd__swatch-text"><span class="lhd__swatch-num">02</span><span class="lhd__swatch-name">Verdigris</span></span></button>
  </nav>
</section>
```

**CSS requirements:**
- `.lhd`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: 1.5rem; padding: 2.25rem 3rem;`
- `.lhd__frame`: `position: relative; width: min(100%, calc((100vh - 250px) * 1.6)); aspect-ratio: 16 / 10; margin: auto; border-radius: 12px; overflow: hidden; background: var(--surface, #121212); touch-action: pan-y;` (pan-y keeps vertical page scroll working on touch while horizontal drags drive the shader).
- `.lhd__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.lhd__caption`: absolute bottom-left/right, mono 11px uppercase, `letter-spacing: .1em; color: rgba(255,255,255,.85); pointer-events: none;`
- `.lhd__swatch`: pill button, `display: flex; gap: .9rem; padding: .5rem .9rem .5rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted);` — `.is-active`/hover: `color: var(--text)`, number in `var(--accent)`. Swatch canvas `64×40px, border-radius: 999px; object-fit: cover`.
- Title: display serif 300, `clamp(2rem, 4.2vw, 3.6rem); letter-spacing: -0.04em`.
- Artwork: paint two 1024×640 canvases. Base `linearGradient(0,0 → 0.4w,h)` through the palette's three stops; then with `globalCompositeOperation = 'screen'` three radial orbs `(x·w, y·h, radius r·w)` from colour → transparent; back to `source-over`, a 6%-tall horizon highlight band; finally per-pixel grain `±10` on RGB via `getImageData`.
  - Dusk Harbour: base `#0b1026 → #2a1740 → #0a0a0a`; orbs `(0.68, 0.38, 0.34, rgba(255,140,80,.95))`, `(0.3, 0.72, 0.42, rgba(180,60,140,.6))`, `(0.1, 0.18, 0.3, rgba(60,140,210,.5))`; horizon at `y = 0.62`, `rgba(255,214,170,.42)`.
  - Verdigris: base `#061a18 → #0e3b35 → #0a0a0a`; orbs `(0.26, 0.3, 0.3, rgba(212,255,58,.7))`, `(0.76, 0.66, 0.36, rgba(40,160,150,.75))`, `(0.86, 0.14, 0.22, rgba(240,200,120,.4))`; horizon at `0.56`, `rgba(212,255,58,.22)`.
  - Draw each big canvas scaled into its swatch canvas with `drawImage(src, 0, 0, 240, 150)`.

**Animation spec (exact values):**
1. Renderer: `new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' })`, `setPixelRatio(Math.min(devicePixelRatio, 2))` (1 when embedded as a preview), `setSize(frame.clientWidth, frame.clientHeight, false)` from a `ResizeObserver` on the frame, which also sets `uAspect = w / h`. `OrthographicCamera(-1, 1, 1, -1, 0, 1)`, `PlaneGeometry(2, 2)`, vertex shader writes `gl_Position = vec4(position.xy, 0.0, 1.0)` and passes `uv`.
2. Textures: `new THREE.CanvasTexture(canvas)` with `generateMipmaps = false; minFilter = magFilter = LinearFilter; wrapS = wrapT = MirroredRepeatWrapping` (displaced UVs that leave 0–1 mirror instead of streaking).
3. Uniforms: `uTex1` (current), `uTex2` (incoming), `uTime: 0`, `uProgress: 0`, `uVelocity: 0`, `uStrength: 1`, `uAspect: 1.6`, `uMouse: Vector2(0.5, 0.5)`. `ShaderMaterial({ uniforms, vertexShader, fragmentShader, depthTest: false, depthWrite: false })`.
4. Pointer: on `pointermove` over the frame compute `x = (clientX - left) / width`, `y = 1 - (clientY - top) / height`; `impulse = min(1.5, impulse + hypot(dx, dy) * 12)` (distance since the previous event); `mouseTarget.set(x, y)`.
5. Per frame (rAF): `uTime += dt`; `uMouse.lerp(mouseTarget, 0.08)`; `impulse *= 0.93`; `uVelocity += (impulse - uVelocity) * 0.1`; `renderer.render(scene, camera)`.
6. Enter/leave the frame: `gsap.to(uStrength, { value: 1, duration: 0.6, ease: 'power2.out' })` on enter; `{ value: 0.35, duration: 1.2, ease: 'power2.out' }` on leave (a faint idle wobble remains).
7. Swap to image `i` (on swatch `pointerenter`, `click` and `focus`): set `uTex2 = textures[i]`, then `gsap.fromTo(uProgress, { value: 0 }, { value: 1, duration: 1.2, ease: 'power3.inOut', onComplete: () => { uTex1 = textures[i]; uProgress = 0; } })`. If the user returns to the outgoing image mid-swap, kill the tween and `gsap.to(uProgress, { value: 0, duration: 1.2 * uProgress, ease: 'power3.inOut' })` instead. Update the caption and `aria-pressed`.
8. Fragment shader (complete):
```glsl
precision highp float;
uniform sampler2D uTex1; uniform sampler2D uTex2;
uniform float uTime, uProgress, uVelocity, uStrength, uAspect;
uniform vec2 uMouse;
varying vec2 vUv;

vec2 mod289(vec2 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x * 34.0) + 1.0) * x); }
vec4 permute(vec4 x){ return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }

float snoise(vec2 v){ // 2D simplex
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
  vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}
float snoise(vec3 v){ // 3D simplex
  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g; vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + C.xxx; vec3 x2 = x0 - i2 + C.yyy; vec3 x3 = x0 - D.yyy; i = mod289(i);
  vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 0.142857142857; vec3 ns = n_ * D.wyz - D.xzx;
  vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);
  vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
  vec3 p0 = vec3(a0.xy, h.x), p1 = vec3(a0.zw, h.y), p2 = vec3(a1.xy, h.z), p3 = vec3(a1.zw, h.w);
  vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m;
  return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}

void main(){
  vec2 uv = vUv; vec2 asp = vec2(uAspect, 1.0);
  float d = distance(uv * asp, uMouse * asp);
  float falloff = smoothstep(0.55, 0.0, d);                       // 1 at the pointer → 0 at radius .55
  float n1 = snoise(vec3(uv * 3.0, uTime * 0.25));
  float n2 = snoise(vec3(uv * 3.0 + 17.0, uTime * 0.25));
  float amp = uStrength * (0.02 + uVelocity * 0.09) * falloff;    // still pointer: 0.02, fast swipe: up to ~0.155
  float wave = sin(uProgress * 3.14159265);                       // swap wobble 0 → 1 → 0
  vec2 duv = uv + vec2(n1, n2) * (amp + wave * 0.06);
  float tn = snoise(uv * 4.0 + vec2(0.0, uTime * 0.1)) * 0.5 + 0.5;
  float pp = uProgress * 1.3 - 0.15;
  float m = 1.0 - smoothstep(pp - 0.15, pp + 0.15, tn);           // liquid mask: 0 everywhere at p=0, 1 everywhere at p=1
  float split = uVelocity * 0.012 * falloff + wave * 0.008;
  vec2 dir = normalize((uv - uMouse) * asp + vec2(1e-4)) * split;
  vec3 c1 = vec3(texture2D(uTex1, duv + dir).r, texture2D(uTex1, duv).g, texture2D(uTex1, duv - dir).b);
  vec3 c2 = vec3(texture2D(uTex2, duv + dir).r, texture2D(uTex2, duv).g, texture2D(uTex2, duv - dir).b);
  vec3 col = mix(c1, c2, m);
  float vig = smoothstep(1.1, 0.35, length((uv - 0.5) * vec2(1.15, 1.0)));
  col *= mix(0.78, 1.0, vig);
  gl_FragColor = vec4(col, 1.0);
}
```
9. Decorative entrance: `gsap.from('.lhd__head, .lhd__frame, .lhd__swatches', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 })`.

**Interaction:** Pointer movement over the frame drives `uMouse` (lagged 0.08) and `uVelocity` (impulse ×12 per uv unit, decays ×0.93/frame, smoothed 0.1) — the image warps and chromatically splits in proportion to speed and settles in ~1s. Entering the frame raises `uStrength` to 1 in 0.6s, leaving lowers it to 0.35 in 1.2s. Hover/click/focus on a swatch triggers the 1.2s `power3.inOut` liquid swap; hovering back mid-swap reverses it.

**Responsive behavior:** The frame is fluid (`min(100%, (100vh - 250px) * 1.6)`) and stays 16:10 at every width; at 375px it is ~300×188px. Touch is **degraded** but live: pointer events fire from `touchmove`, so dragging a finger across the frame drives `uMouse`/`uVelocity` (with `touch-action: pan-y` so vertical scrolling still works); swatches respond to tap via `click`. Under 768px the header stacks and swatch names collapse to numbers. No `gsap.matchMedia()` branches are required because the shader is resolution-independent; DPR is capped at 2 (1 in preview embeds).

**Accessibility:** `prefers-reduced-motion: reduce` → `uStrength = 0` and `uTime` is frozen (no displacement, no idle wobble), `uMouse` is set directly without lag, and swaps are instant (`uTex1` replaced, `uProgress = 0`). The canvas has `role="img"` with an `aria-label`; swatches are real `<button>`s with `aria-pressed`, respond to keyboard focus and show a `2px solid var(--accent)` focus ring; the caption text mirrors the current image name.

**Performance rules:** One draw call, six texture lookups per fragment, no post-processing. DPR `Math.min(devicePixelRatio, 2)`, halved for previews. The rAF loop stops on the host `ml:pause` event and on `visibilitychange` (hidden) and restarts on `ml:resume`/visible. `ResizeObserver` instead of per-frame size reads. On unmount: cancel the rAF, remove listeners, disconnect the observer, kill the swap tween, `texture.dispose()`, `geometry.dispose()`, `material.dispose()`, `renderer.dispose()`.

**Customization knobs:**
- `MOUSE_LERP` (0.08) — lag of the distortion centre.
- `VEL_GAIN` (12) / `VEL_DECAY` (0.93) — how hard a swipe hits and how long it lingers.
- `0.55` falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `0.012` RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).

**Acceptance criteria:**
- [ ] At rest the image is sharp with only a faint slow wobble near the centre.
- [ ] Moving the pointer bends the image around a lagging centre; faster movement = deeper warp and visible red/blue fringing along the motion direction.
- [ ] The warp decays within ~1s of the pointer stopping.
- [ ] Hovering the second swatch melts to the second image along a noisy edge in 1.2s; both images wobble during the swap and settle still afterwards; caption and `aria-pressed` update.
- [ ] Reduced-motion users see static images and instant swaps; the rAF loop halts when the tab is hidden; no console errors or WebGL warnings.

Source

Full source code

<section class="lhd">
  <header class="lhd__head">
    <div>
      <p class="lhd__eyebrow">WebGL · ShaderMaterial</p>
      <h1 class="lhd__title">Surface tension.</h1>
    </div>
    <p class="lhd__hint">Move across the image · hover a swatch to change it</p>
  </header>

  <div class="lhd__frame">
    <canvas class="lhd__canvas" aria-label="Interactive liquid distortion of a painted landscape" role="img"></canvas>
    <p class="lhd__caption"><span class="lhd__caption-name">Dusk Harbour</span><span class="lhd__caption-index">01 / 02</span></p>
  </div>

  <nav class="lhd__swatches" aria-label="Choose an image">
    <button class="lhd__swatch lhd__swatch--1 is-active" type="button" data-index="0" aria-pressed="true">
      <canvas class="lhd__swatch-art" width="240" height="150" aria-hidden="true"></canvas>
      <span class="lhd__swatch-text"><span class="lhd__swatch-num">01</span><span class="lhd__swatch-name">Dusk Harbour</span></span>
    </button>
    <button class="lhd__swatch lhd__swatch--2" type="button" data-index="1" aria-pressed="false">
      <canvas class="lhd__swatch-art" width="240" height="150" aria-hidden="true"></canvas>
      <span class="lhd__swatch-text"><span class="lhd__swatch-num">02</span><span class="lhd__swatch-name">Verdigris</span></span>
    </button>
  </nav>
</section>