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.
- WebGL & 3D
- threejs
- glsl
- gsap
- canvas2d
- advanced
- heavy · WebGL
- mobile: degraded
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 2195 words of exact values — rewritten live as you tune the Playground.
### 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 (idle wobble after the pointer leaves: true). When the pointer moves across the frame, the image bends like the surface of water: a soft zone of radius 0.55 (in uv units, aspect-corrected) around the pointer displaces, following the pointer with a slight lag (the distortion centre lerps toward the real pointer by 0.08 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`, `uRadius: 0.55` (falloff radius), `uSplit: 0.012` (RGB split strength), `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: true ? 0.35 : 0, duration: 1.2, ease: 'power2.out' }` on leave (with the idle wobble enabled a faint ripple remains; otherwise the image goes perfectly still).
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, uRadius, uSplit;
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(uRadius, 0.0, d); // 1 at the pointer → 0 at radius uRadius
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 * uSplit * 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 (or 0 when the idle wobble is off: true) 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.
- `uRadius` (0.55) falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `uSplit` (0.012) RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).
- Idle wobble (true) — resting `uStrength` after the pointer leaves (0.35 on, 0 off).
**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. Add this effect to my existing site/section: **Liquid Hover Distortion (WebGL)**. Replace my hero `<img>` with a `.lhd__frame` (same size, `overflow: hidden`, `touch-action: pan-y`) containing a `<canvas>`, and render my image(s) as `THREE.CanvasTexture`/`TextureLoader` textures on a `PlaneGeometry(2, 2)` with an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and the `ShaderMaterial` above (uniforms `uTex1, uTex2, uTime, uProgress, uVelocity, uStrength, uAspect, uMouse`, plus `uRadius = 0.55` and `uSplit = 0.012`, full GLSL from the animation spec including the simplex noise). On `pointermove` set `mouseTarget` in uv space and add `hypot(dx, dy) * 12` to an impulse; each frame `uMouse.lerp(mouseTarget, 0.08)`, `impulse *= 0.93`, `uVelocity += (impulse - uVelocity) * 0.1`. For each of my thumbnails/links, on hover tween `uProgress 0→1` over `1.2s power3.inOut` with the incoming texture in `uTex2`, then promote it to `uTex1`. Cap DPR at 2, pause the loop when hidden, dispose renderer/textures/material on unmount, lower `uStrength` to true ? 0.35 : 0 when the pointer leaves, and for `prefers-reduced-motion` set `uStrength = 0` with instant swaps. Keep my layout, fonts and colours. ### EFFECT (React / Next.js): Liquid Hover Distortion (WebGL)
Build **Liquid Hover Distortion (WebGL)** 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/LiquidHoverDistortionWebGL.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP);
export default function LiquidHoverDistortionWebGL() {
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="lhd">
{/* 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 (`LiquidHoverDistortionWebGL.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: 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 (idle wobble after the pointer leaves: true). When the pointer moves across the frame, the image bends like the surface of water: a soft zone of radius 0.55 (in uv units, aspect-corrected) around the pointer displaces, following the pointer with a slight lag (the distortion centre lerps toward the real pointer by 0.08 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`, `uRadius: 0.55` (falloff radius), `uSplit: 0.012` (RGB split strength), `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: true ? 0.35 : 0, duration: 1.2, ease: 'power2.out' }` on leave (with the idle wobble enabled a faint ripple remains; otherwise the image goes perfectly still).
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, uRadius, uSplit;
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(uRadius, 0.0, d); // 1 at the pointer → 0 at radius uRadius
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 * uSplit * 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 (or 0 when the idle wobble is off: true) 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.
- `uRadius` (0.55) falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `uSplit` (0.012) RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).
- Idle wobble (true) — resting `uStrength` after the pointer leaves (0.35 on, 0 off).
**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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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" 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>
```
```css
.lhd { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(1rem, 2.5vw, 1.5rem); padding: clamp(1rem, 3vw, 2.25rem) clamp(1rem, 4vw, 3rem); }
.lhd__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1.5rem; }
.lhd__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: .6rem; }
.lhd__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2rem, 4.2vw, 3.6rem); line-height: .95; letter-spacing: -0.04em; }
.lhd__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); text-align: right; max-width: 30ch; }
.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); box-shadow: 0 30px 80px -30px rgba(0,0,0,.7); touch-action: pan-y; }
.lhd__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lhd__caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.85); pointer-events: none; }
.lhd__swatches { display: flex; justify-content: center; gap: clamp(.75rem, 2vw, 1.5rem); }
.lhd__swatch { display: flex; align-items: center; gap: .9rem; padding: .5rem .9rem .5rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); transition: border-color .3s, color .3s; }
.lhd__swatch:hover, .lhd__swatch.is-active { color: var(--text); border-color: color-mix(in srgb, var(--text) 30%, transparent); }
.lhd__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.lhd__swatch-art { width: 64px; height: 40px; border-radius: 999px; object-fit: cover; }
.lhd__swatch-text { display: flex; gap: .7rem; align-items: baseline; font-size: .85rem; }
.lhd__swatch-num { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; opacity: .6; }
.lhd__swatch.is-active .lhd__swatch-num { color: var(--accent); opacity: 1; }
@media (max-width: 767px) {
.lhd__head { flex-direction: column; align-items: flex-start; }
.lhd__hint { text-align: left; }
.lhd__swatch-name { display: none; }
}
@media (prefers-reduced-motion: reduce) { .lhd__swatch { transition: none; } }
```
```js
// Liquid Hover Distortion — a ShaderMaterial plane that warps a painted image under the pointer and melts between two images.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { mouseLerp: 0.08, velGain: 12, radius: 0.55, split: 0.012, swapDuration: 1.2, idleWobble: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const VEL_DECAY = 0.93; // per-frame decay of the pointer-speed impulse
// Live parameters (filled by build(); the render loop and handlers read them every frame)
const P = { mouseLerp: DEFAULTS.mouseLerp, velGain: DEFAULTS.velGain, swapDuration: DEFAULTS.swapDuration, restStrength: 0.35 };
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const frame = document.querySelector('.lhd__frame');
const canvas = document.querySelector('.lhd__canvas');
const swatches = gsap.utils.toArray('.lhd__swatch');
const captionName = document.querySelector('.lhd__caption-name');
const captionIndex = document.querySelector('.lhd__caption-index');
// ---------- procedural artwork (1024x640 canvases → CanvasTexture) ----------
const PALETTES = [
{ name: 'Dusk Harbour', base: ['#0b1026', '#2a1740', '#0a0a0a'], horizon: [0.62, 'rgba(255,214,170,.42)'],
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)']] },
{ name: 'Verdigris', base: ['#061a18', '#0e3b35', '#0a0a0a'], horizon: [0.56, 'rgba(212,255,58,.22)'],
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)']] },
];
function paint(p, w = 1024, h = 640) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.4, h);
p.base.forEach((col, i) => base.addColorStop(i / (p.base.length - 1), col));
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
for (const [x, y, r, col] of p.orbs) {
const rg = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, r * w);
rg.addColorStop(0, col); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
const [hy, hc] = p.horizon;
const hz = g.createLinearGradient(0, h * (hy - 0.03), 0, h * (hy + 0.03));
hz.addColorStop(0, 'rgba(0,0,0,0)'); hz.addColorStop(0.5, hc); hz.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = hz; g.fillRect(0, h * (hy - 0.03), w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 20; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
return c;
}
const sources = PALETTES.map((p) => paint(p));
swatches.forEach((b, i) => b.querySelector('.lhd__swatch-art').getContext('2d').drawImage(sources[i], 0, 0, 240, 150));
// ---------- shaders ----------
const vertexShader = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const fragmentShader = /* glsl */`
precision highp float;
uniform sampler2D uTex1;
uniform sampler2D uTex2;
uniform float uTime;
uniform float uProgress;
uniform float uVelocity;
uniform float uStrength;
uniform float uAspect;
uniform float uRadius;
uniform float uSplit;
uniform vec2 uMouse;
varying vec2 vUv;
// ---- simplex noise (Ashima Arts / Stefan Gustavson, MIT) ----
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){
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){
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);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 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);
// radial falloff around the (lerped) pointer, in aspect-correct space
float d = distance(uv * asp, uMouse * asp);
float falloff = smoothstep(uRadius, 0.0, d);
// animated displacement field
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;
float wave = sin(uProgress * 3.14159265); // 0 → 1 → 0 across the swap
vec2 disp = vec2(n1, n2) * (amp + wave * 0.06);
vec2 duv = uv + disp;
// liquid mask for the texture swap
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);
// RGB split along the pointer direction, proportional to velocity
float split = uVelocity * uSplit * 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);
// soft vignette
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);
}
`;
// ---------- three.js ----------
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const makeTexture = (src) => {
const t = new THREE.CanvasTexture(src);
t.generateMipmaps = false; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter;
t.wrapS = t.wrapT = THREE.MirroredRepeatWrapping; // displaced UVs past the edge mirror instead of streaking
return t;
};
const textures = sources.map(makeTexture);
const uniforms = {
uTex1: { value: textures[0] }, uTex2: { value: textures[1] },
uTime: { value: 0 }, uProgress: { value: 0 }, uVelocity: { value: 0 },
uStrength: { value: reduced ? 0 : 1 }, uAspect: { value: 1.6 }, uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uRadius: { value: DEFAULTS.radius }, uSplit: { value: DEFAULTS.split },
};
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
const size = () => {
const w = frame.clientWidth, h = frame.clientHeight;
renderer.setSize(w, h, false);
uniforms.uAspect.value = w / h;
};
size();
const ro = new ResizeObserver(size); ro.observe(frame);
// ---------- pointer → uniforms ----------
const mouseTarget = new THREE.Vector2(0.5, 0.5);
const last = new THREE.Vector2();
let impulse = 0, hasLast = false, inside = false;
const onMove = (e) => {
const r = frame.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height;
if (hasLast) impulse = Math.min(1.5, impulse + Math.hypot(x - last.x, y - last.y) * P.velGain);
last.set(x, y); hasLast = true;
mouseTarget.set(x, y);
if (reduced) uniforms.uMouse.value.copy(mouseTarget);
};
const onEnter = () => { hasLast = false; inside = true; if (!reduced) gsap.to(uniforms.uStrength, { value: 1, duration: 0.6, ease: 'power2.out', overwrite: true }); };
const onLeave = () => { hasLast = false; inside = false; if (!reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 1.2, ease: 'power2.out', overwrite: true }); };
frame.addEventListener('pointermove', onMove, { passive: true });
frame.addEventListener('pointerenter', onEnter);
frame.addEventListener('pointerleave', onLeave);
// ---------- texture swap ----------
let current = 0, swapTween;
function setCaption(i) {
captionName.textContent = PALETTES[i].name;
captionIndex.textContent = `0${i + 1} / 0${PALETTES.length}`;
swatches.forEach((b, j) => { b.classList.toggle('is-active', j === i); b.setAttribute('aria-pressed', String(j === i)); });
}
function finalize(i) { uniforms.uTex1.value = textures[i]; uniforms.uProgress.value = 0; }
function swapTo(i) {
if (i === current) return;
const previous = current;
current = i;
setCaption(i);
if (reduced) { swapTween?.kill(); finalize(i); return; }
if (swapTween?.isActive() && uniforms.uTex2.value === textures[previous] && uniforms.uTex1.value === textures[i]) {
// user went back mid-swap: just reverse the progress
swapTween.kill();
swapTween = gsap.to(uniforms.uProgress, { value: 0, duration: P.swapDuration * uniforms.uProgress.value, ease: 'power3.inOut' });
return;
}
if (swapTween?.isActive()) { swapTween.kill(); finalize(previous); }
uniforms.uTex2.value = textures[i];
swapTween = gsap.fromTo(uniforms.uProgress, { value: 0 }, { value: 1, duration: P.swapDuration, ease: 'power3.inOut', onComplete: () => finalize(i) });
}
const swatchHandlers = swatches.map((b) => {
const fn = () => swapTo(Number(b.dataset.index));
b.addEventListener('pointerenter', fn);
b.addEventListener('click', fn);
b.addEventListener('focus', fn);
return () => { b.removeEventListener('pointerenter', fn); b.removeEventListener('click', fn); b.removeEventListener('focus', fn); };
});
// ---------- render loop (pauses on ml:pause and when the tab is hidden) ----------
let raf = 0, running = false, prev = performance.now();
const tick = (now) => {
if (!running) return;
const dt = Math.min(0.05, (now - prev) / 1000); prev = now;
if (!reduced) {
uniforms.uTime.value += dt;
uniforms.uMouse.value.lerp(mouseTarget, P.mouseLerp);
impulse *= VEL_DECAY;
uniforms.uVelocity.value += (impulse - uniforms.uVelocity.value) * 0.1;
}
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
const start = () => { if (running || document.hidden) return; running = true; prev = performance.now(); raf = requestAnimationFrame(tick); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
const ctx = gsap.context(() => {
if (!reduced) gsap.from('.lhd__head, .lhd__frame, .lhd__swatches', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 });
});
// ---------- knobs → params + uniforms (the renderer, textures and listeners are kept; changes are visible on the next frame) ----------
let built = false;
function build() {
P.mouseLerp = K('mouseLerp');
P.velGain = K('velGain');
P.swapDuration = K('swapDuration');
P.restStrength = K('idleWobble') ? 0.35 : 0;
uniforms.uRadius.value = K('radius');
uniforms.uSplit.value = K('split');
// pointer currently outside the frame: ease to the new resting strength so the idle toggle is visible immediately
if (built && !inside && !reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 0.6, ease: 'power2.out', overwrite: true });
built = true;
}
build();
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
stop();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
frame.removeEventListener('pointermove', onMove);
frame.removeEventListener('pointerenter', onEnter);
frame.removeEventListener('pointerleave', onLeave);
swatchHandlers.forEach((fn) => fn());
ro.disconnect();
swapTween?.kill();
ctx.revert();
textures.forEach((t) => t.dispose());
geometry.dispose(); material.dispose(); renderer.dispose();
});
```
### EFFECT (single HTML file): Liquid Hover Distortion (WebGL)
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Liquid Hover Distortion (WebGL)** 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>Liquid Hover Distortion (WebGL)</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 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: 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 (idle wobble after the pointer leaves: true). When the pointer moves across the frame, the image bends like the surface of water: a soft zone of radius 0.55 (in uv units, aspect-corrected) around the pointer displaces, following the pointer with a slight lag (the distortion centre lerps toward the real pointer by 0.08 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`, `uRadius: 0.55` (falloff radius), `uSplit: 0.012` (RGB split strength), `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: true ? 0.35 : 0, duration: 1.2, ease: 'power2.out' }` on leave (with the idle wobble enabled a faint ripple remains; otherwise the image goes perfectly still).
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, uRadius, uSplit;
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(uRadius, 0.0, d); // 1 at the pointer → 0 at radius uRadius
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 * uSplit * 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 (or 0 when the idle wobble is off: true) 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.
- `uRadius` (0.55) falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `uSplit` (0.012) RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).
- Idle wobble (true) — resting `uStrength` after the pointer leaves (0.35 on, 0 off).
**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.
---
**Reference implementation (use these exact values and class names):**
```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" 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>
```
```css
.lhd { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(1rem, 2.5vw, 1.5rem); padding: clamp(1rem, 3vw, 2.25rem) clamp(1rem, 4vw, 3rem); }
.lhd__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1.5rem; }
.lhd__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: .6rem; }
.lhd__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2rem, 4.2vw, 3.6rem); line-height: .95; letter-spacing: -0.04em; }
.lhd__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); text-align: right; max-width: 30ch; }
.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); box-shadow: 0 30px 80px -30px rgba(0,0,0,.7); touch-action: pan-y; }
.lhd__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lhd__caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.85); pointer-events: none; }
.lhd__swatches { display: flex; justify-content: center; gap: clamp(.75rem, 2vw, 1.5rem); }
.lhd__swatch { display: flex; align-items: center; gap: .9rem; padding: .5rem .9rem .5rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); transition: border-color .3s, color .3s; }
.lhd__swatch:hover, .lhd__swatch.is-active { color: var(--text); border-color: color-mix(in srgb, var(--text) 30%, transparent); }
.lhd__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.lhd__swatch-art { width: 64px; height: 40px; border-radius: 999px; object-fit: cover; }
.lhd__swatch-text { display: flex; gap: .7rem; align-items: baseline; font-size: .85rem; }
.lhd__swatch-num { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; opacity: .6; }
.lhd__swatch.is-active .lhd__swatch-num { color: var(--accent); opacity: 1; }
@media (max-width: 767px) {
.lhd__head { flex-direction: column; align-items: flex-start; }
.lhd__hint { text-align: left; }
.lhd__swatch-name { display: none; }
}
@media (prefers-reduced-motion: reduce) { .lhd__swatch { transition: none; } }
```
```js
// Liquid Hover Distortion — a ShaderMaterial plane that warps a painted image under the pointer and melts between two images.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { mouseLerp: 0.08, velGain: 12, radius: 0.55, split: 0.012, swapDuration: 1.2, idleWobble: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const VEL_DECAY = 0.93; // per-frame decay of the pointer-speed impulse
// Live parameters (filled by build(); the render loop and handlers read them every frame)
const P = { mouseLerp: DEFAULTS.mouseLerp, velGain: DEFAULTS.velGain, swapDuration: DEFAULTS.swapDuration, restStrength: 0.35 };
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const frame = document.querySelector('.lhd__frame');
const canvas = document.querySelector('.lhd__canvas');
const swatches = gsap.utils.toArray('.lhd__swatch');
const captionName = document.querySelector('.lhd__caption-name');
const captionIndex = document.querySelector('.lhd__caption-index');
// ---------- procedural artwork (1024x640 canvases → CanvasTexture) ----------
const PALETTES = [
{ name: 'Dusk Harbour', base: ['#0b1026', '#2a1740', '#0a0a0a'], horizon: [0.62, 'rgba(255,214,170,.42)'],
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)']] },
{ name: 'Verdigris', base: ['#061a18', '#0e3b35', '#0a0a0a'], horizon: [0.56, 'rgba(212,255,58,.22)'],
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)']] },
];
function paint(p, w = 1024, h = 640) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.4, h);
p.base.forEach((col, i) => base.addColorStop(i / (p.base.length - 1), col));
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
for (const [x, y, r, col] of p.orbs) {
const rg = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, r * w);
rg.addColorStop(0, col); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
const [hy, hc] = p.horizon;
const hz = g.createLinearGradient(0, h * (hy - 0.03), 0, h * (hy + 0.03));
hz.addColorStop(0, 'rgba(0,0,0,0)'); hz.addColorStop(0.5, hc); hz.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = hz; g.fillRect(0, h * (hy - 0.03), w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 20; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
return c;
}
const sources = PALETTES.map((p) => paint(p));
swatches.forEach((b, i) => b.querySelector('.lhd__swatch-art').getContext('2d').drawImage(sources[i], 0, 0, 240, 150));
// ---------- shaders ----------
const vertexShader = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const fragmentShader = /* glsl */`
precision highp float;
uniform sampler2D uTex1;
uniform sampler2D uTex2;
uniform float uTime;
uniform float uProgress;
uniform float uVelocity;
uniform float uStrength;
uniform float uAspect;
uniform float uRadius;
uniform float uSplit;
uniform vec2 uMouse;
varying vec2 vUv;
// ---- simplex noise (Ashima Arts / Stefan Gustavson, MIT) ----
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){
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){
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);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 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);
// radial falloff around the (lerped) pointer, in aspect-correct space
float d = distance(uv * asp, uMouse * asp);
float falloff = smoothstep(uRadius, 0.0, d);
// animated displacement field
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;
float wave = sin(uProgress * 3.14159265); // 0 → 1 → 0 across the swap
vec2 disp = vec2(n1, n2) * (amp + wave * 0.06);
vec2 duv = uv + disp;
// liquid mask for the texture swap
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);
// RGB split along the pointer direction, proportional to velocity
float split = uVelocity * uSplit * 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);
// soft vignette
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);
}
`;
// ---------- three.js ----------
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const makeTexture = (src) => {
const t = new THREE.CanvasTexture(src);
t.generateMipmaps = false; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter;
t.wrapS = t.wrapT = THREE.MirroredRepeatWrapping; // displaced UVs past the edge mirror instead of streaking
return t;
};
const textures = sources.map(makeTexture);
const uniforms = {
uTex1: { value: textures[0] }, uTex2: { value: textures[1] },
uTime: { value: 0 }, uProgress: { value: 0 }, uVelocity: { value: 0 },
uStrength: { value: reduced ? 0 : 1 }, uAspect: { value: 1.6 }, uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uRadius: { value: DEFAULTS.radius }, uSplit: { value: DEFAULTS.split },
};
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
const size = () => {
const w = frame.clientWidth, h = frame.clientHeight;
renderer.setSize(w, h, false);
uniforms.uAspect.value = w / h;
};
size();
const ro = new ResizeObserver(size); ro.observe(frame);
// ---------- pointer → uniforms ----------
const mouseTarget = new THREE.Vector2(0.5, 0.5);
const last = new THREE.Vector2();
let impulse = 0, hasLast = false, inside = false;
const onMove = (e) => {
const r = frame.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height;
if (hasLast) impulse = Math.min(1.5, impulse + Math.hypot(x - last.x, y - last.y) * P.velGain);
last.set(x, y); hasLast = true;
mouseTarget.set(x, y);
if (reduced) uniforms.uMouse.value.copy(mouseTarget);
};
const onEnter = () => { hasLast = false; inside = true; if (!reduced) gsap.to(uniforms.uStrength, { value: 1, duration: 0.6, ease: 'power2.out', overwrite: true }); };
const onLeave = () => { hasLast = false; inside = false; if (!reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 1.2, ease: 'power2.out', overwrite: true }); };
frame.addEventListener('pointermove', onMove, { passive: true });
frame.addEventListener('pointerenter', onEnter);
frame.addEventListener('pointerleave', onLeave);
// ---------- texture swap ----------
let current = 0, swapTween;
function setCaption(i) {
captionName.textContent = PALETTES[i].name;
captionIndex.textContent = `0${i + 1} / 0${PALETTES.length}`;
swatches.forEach((b, j) => { b.classList.toggle('is-active', j === i); b.setAttribute('aria-pressed', String(j === i)); });
}
function finalize(i) { uniforms.uTex1.value = textures[i]; uniforms.uProgress.value = 0; }
function swapTo(i) {
if (i === current) return;
const previous = current;
current = i;
setCaption(i);
if (reduced) { swapTween?.kill(); finalize(i); return; }
if (swapTween?.isActive() && uniforms.uTex2.value === textures[previous] && uniforms.uTex1.value === textures[i]) {
// user went back mid-swap: just reverse the progress
swapTween.kill();
swapTween = gsap.to(uniforms.uProgress, { value: 0, duration: P.swapDuration * uniforms.uProgress.value, ease: 'power3.inOut' });
return;
}
if (swapTween?.isActive()) { swapTween.kill(); finalize(previous); }
uniforms.uTex2.value = textures[i];
swapTween = gsap.fromTo(uniforms.uProgress, { value: 0 }, { value: 1, duration: P.swapDuration, ease: 'power3.inOut', onComplete: () => finalize(i) });
}
const swatchHandlers = swatches.map((b) => {
const fn = () => swapTo(Number(b.dataset.index));
b.addEventListener('pointerenter', fn);
b.addEventListener('click', fn);
b.addEventListener('focus', fn);
return () => { b.removeEventListener('pointerenter', fn); b.removeEventListener('click', fn); b.removeEventListener('focus', fn); };
});
// ---------- render loop (pauses on ml:pause and when the tab is hidden) ----------
let raf = 0, running = false, prev = performance.now();
const tick = (now) => {
if (!running) return;
const dt = Math.min(0.05, (now - prev) / 1000); prev = now;
if (!reduced) {
uniforms.uTime.value += dt;
uniforms.uMouse.value.lerp(mouseTarget, P.mouseLerp);
impulse *= VEL_DECAY;
uniforms.uVelocity.value += (impulse - uniforms.uVelocity.value) * 0.1;
}
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
const start = () => { if (running || document.hidden) return; running = true; prev = performance.now(); raf = requestAnimationFrame(tick); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
const ctx = gsap.context(() => {
if (!reduced) gsap.from('.lhd__head, .lhd__frame, .lhd__swatches', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 });
});
// ---------- knobs → params + uniforms (the renderer, textures and listeners are kept; changes are visible on the next frame) ----------
let built = false;
function build() {
P.mouseLerp = K('mouseLerp');
P.velGain = K('velGain');
P.swapDuration = K('swapDuration');
P.restStrength = K('idleWobble') ? 0.35 : 0;
uniforms.uRadius.value = K('radius');
uniforms.uSplit.value = K('split');
// pointer currently outside the frame: ease to the new resting strength so the idle toggle is visible immediately
if (built && !inside && !reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 0.6, ease: 'power2.out', overwrite: true });
built = true;
}
build();
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
stop();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
frame.removeEventListener('pointermove', onMove);
frame.removeEventListener('pointerenter', onEnter);
frame.removeEventListener('pointerleave', onLeave);
swatchHandlers.forEach((fn) => fn());
ro.disconnect();
swapTween?.kill();
ctx.revert();
textures.forEach((t) => t.dispose());
geometry.dispose(); material.dispose(); renderer.dispose();
});
```
### EFFECT (Framer & Webflow notes): Liquid Hover Distortion (WebGL)
**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 withLiquidHoverDistortionWebGL(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 `lhd`), 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 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: 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 (idle wobble after the pointer leaves: true). When the pointer moves across the frame, the image bends like the surface of water: a soft zone of radius 0.55 (in uv units, aspect-corrected) around the pointer displaces, following the pointer with a slight lag (the distortion centre lerps toward the real pointer by 0.08 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`, `uRadius: 0.55` (falloff radius), `uSplit: 0.012` (RGB split strength), `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: true ? 0.35 : 0, duration: 1.2, ease: 'power2.out' }` on leave (with the idle wobble enabled a faint ripple remains; otherwise the image goes perfectly still).
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, uRadius, uSplit;
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(uRadius, 0.0, d); // 1 at the pointer → 0 at radius uRadius
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 * uSplit * 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 (or 0 when the idle wobble is off: true) 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.
- `uRadius` (0.55) falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `uSplit` (0.012) RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).
- Idle wobble (true) — resting `uStrength` after the pointer leaves (0.35 on, 0 off).
**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>
.lhd { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(1rem, 2.5vw, 1.5rem); padding: clamp(1rem, 3vw, 2.25rem) clamp(1rem, 4vw, 3rem); }
.lhd__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1.5rem; }
.lhd__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: .6rem; }
.lhd__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2rem, 4.2vw, 3.6rem); line-height: .95; letter-spacing: -0.04em; }
.lhd__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); text-align: right; max-width: 30ch; }
.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); box-shadow: 0 30px 80px -30px rgba(0,0,0,.7); touch-action: pan-y; }
.lhd__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lhd__caption { position: absolute; left: 1.25rem; right: 1.25rem; bottom: 1rem; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: rgba(255,255,255,.85); pointer-events: none; }
.lhd__swatches { display: flex; justify-content: center; gap: clamp(.75rem, 2vw, 1.5rem); }
.lhd__swatch { display: flex; align-items: center; gap: .9rem; padding: .5rem .9rem .5rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); transition: border-color .3s, color .3s; }
.lhd__swatch:hover, .lhd__swatch.is-active { color: var(--text); border-color: color-mix(in srgb, var(--text) 30%, transparent); }
.lhd__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.lhd__swatch-art { width: 64px; height: 40px; border-radius: 999px; object-fit: cover; }
.lhd__swatch-text { display: flex; gap: .7rem; align-items: baseline; font-size: .85rem; }
.lhd__swatch-num { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; opacity: .6; }
.lhd__swatch.is-active .lhd__swatch-num { color: var(--accent); opacity: 1; }
@media (max-width: 767px) {
.lhd__head { flex-direction: column; align-items: flex-start; }
.lhd__hint { text-align: left; }
.lhd__swatch-name { display: none; }
}
@media (prefers-reduced-motion: reduce) { .lhd__swatch { transition: none; } }
// Liquid Hover Distortion — a ShaderMaterial plane that warps a painted image under the pointer and melts between two images.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { mouseLerp: 0.08, velGain: 12, radius: 0.55, split: 0.012, swapDuration: 1.2, idleWobble: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const VEL_DECAY = 0.93; // per-frame decay of the pointer-speed impulse
// Live parameters (filled by build(); the render loop and handlers read them every frame)
const P = { mouseLerp: DEFAULTS.mouseLerp, velGain: DEFAULTS.velGain, swapDuration: DEFAULTS.swapDuration, restStrength: 0.35 };
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const frame = document.querySelector('.lhd__frame');
const canvas = document.querySelector('.lhd__canvas');
const swatches = gsap.utils.toArray('.lhd__swatch');
const captionName = document.querySelector('.lhd__caption-name');
const captionIndex = document.querySelector('.lhd__caption-index');
// ---------- procedural artwork (1024x640 canvases → CanvasTexture) ----------
const PALETTES = [
{ name: 'Dusk Harbour', base: ['#0b1026', '#2a1740', '#0a0a0a'], horizon: [0.62, 'rgba(255,214,170,.42)'],
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)']] },
{ name: 'Verdigris', base: ['#061a18', '#0e3b35', '#0a0a0a'], horizon: [0.56, 'rgba(212,255,58,.22)'],
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)']] },
];
function paint(p, w = 1024, h = 640) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
const base = g.createLinearGradient(0, 0, w * 0.4, h);
p.base.forEach((col, i) => base.addColorStop(i / (p.base.length - 1), col));
g.fillStyle = base; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'screen';
for (const [x, y, r, col] of p.orbs) {
const rg = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, r * w);
rg.addColorStop(0, col); rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
const [hy, hc] = p.horizon;
const hz = g.createLinearGradient(0, h * (hy - 0.03), 0, h * (hy + 0.03));
hz.addColorStop(0, 'rgba(0,0,0,0)'); hz.addColorStop(0.5, hc); hz.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = hz; g.fillRect(0, h * (hy - 0.03), w, h * 0.06);
const img = g.getImageData(0, 0, w, h), d = img.data;
for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 20; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
g.putImageData(img, 0, 0);
return c;
}
const sources = PALETTES.map((p) => paint(p));
swatches.forEach((b, i) => b.querySelector('.lhd__swatch-art').getContext('2d').drawImage(sources[i], 0, 0, 240, 150));
// ---------- shaders ----------
const vertexShader = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const fragmentShader = /* glsl */`
precision highp float;
uniform sampler2D uTex1;
uniform sampler2D uTex2;
uniform float uTime;
uniform float uProgress;
uniform float uVelocity;
uniform float uStrength;
uniform float uAspect;
uniform float uRadius;
uniform float uSplit;
uniform vec2 uMouse;
varying vec2 vUv;
// ---- simplex noise (Ashima Arts / Stefan Gustavson, MIT) ----
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){
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){
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);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 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);
// radial falloff around the (lerped) pointer, in aspect-correct space
float d = distance(uv * asp, uMouse * asp);
float falloff = smoothstep(uRadius, 0.0, d);
// animated displacement field
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;
float wave = sin(uProgress * 3.14159265); // 0 → 1 → 0 across the swap
vec2 disp = vec2(n1, n2) * (amp + wave * 0.06);
vec2 duv = uv + disp;
// liquid mask for the texture swap
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);
// RGB split along the pointer direction, proportional to velocity
float split = uVelocity * uSplit * 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);
// soft vignette
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);
}
`;
// ---------- three.js ----------
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const makeTexture = (src) => {
const t = new THREE.CanvasTexture(src);
t.generateMipmaps = false; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter;
t.wrapS = t.wrapT = THREE.MirroredRepeatWrapping; // displaced UVs past the edge mirror instead of streaking
return t;
};
const textures = sources.map(makeTexture);
const uniforms = {
uTex1: { value: textures[0] }, uTex2: { value: textures[1] },
uTime: { value: 0 }, uProgress: { value: 0 }, uVelocity: { value: 0 },
uStrength: { value: reduced ? 0 : 1 }, uAspect: { value: 1.6 }, uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uRadius: { value: DEFAULTS.radius }, uSplit: { value: DEFAULTS.split },
};
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
const size = () => {
const w = frame.clientWidth, h = frame.clientHeight;
renderer.setSize(w, h, false);
uniforms.uAspect.value = w / h;
};
size();
const ro = new ResizeObserver(size); ro.observe(frame);
// ---------- pointer → uniforms ----------
const mouseTarget = new THREE.Vector2(0.5, 0.5);
const last = new THREE.Vector2();
let impulse = 0, hasLast = false, inside = false;
const onMove = (e) => {
const r = frame.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height;
if (hasLast) impulse = Math.min(1.5, impulse + Math.hypot(x - last.x, y - last.y) * P.velGain);
last.set(x, y); hasLast = true;
mouseTarget.set(x, y);
if (reduced) uniforms.uMouse.value.copy(mouseTarget);
};
const onEnter = () => { hasLast = false; inside = true; if (!reduced) gsap.to(uniforms.uStrength, { value: 1, duration: 0.6, ease: 'power2.out', overwrite: true }); };
const onLeave = () => { hasLast = false; inside = false; if (!reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 1.2, ease: 'power2.out', overwrite: true }); };
frame.addEventListener('pointermove', onMove, { passive: true });
frame.addEventListener('pointerenter', onEnter);
frame.addEventListener('pointerleave', onLeave);
// ---------- texture swap ----------
let current = 0, swapTween;
function setCaption(i) {
captionName.textContent = PALETTES[i].name;
captionIndex.textContent = `0${i + 1} / 0${PALETTES.length}`;
swatches.forEach((b, j) => { b.classList.toggle('is-active', j === i); b.setAttribute('aria-pressed', String(j === i)); });
}
function finalize(i) { uniforms.uTex1.value = textures[i]; uniforms.uProgress.value = 0; }
function swapTo(i) {
if (i === current) return;
const previous = current;
current = i;
setCaption(i);
if (reduced) { swapTween?.kill(); finalize(i); return; }
if (swapTween?.isActive() && uniforms.uTex2.value === textures[previous] && uniforms.uTex1.value === textures[i]) {
// user went back mid-swap: just reverse the progress
swapTween.kill();
swapTween = gsap.to(uniforms.uProgress, { value: 0, duration: P.swapDuration * uniforms.uProgress.value, ease: 'power3.inOut' });
return;
}
if (swapTween?.isActive()) { swapTween.kill(); finalize(previous); }
uniforms.uTex2.value = textures[i];
swapTween = gsap.fromTo(uniforms.uProgress, { value: 0 }, { value: 1, duration: P.swapDuration, ease: 'power3.inOut', onComplete: () => finalize(i) });
}
const swatchHandlers = swatches.map((b) => {
const fn = () => swapTo(Number(b.dataset.index));
b.addEventListener('pointerenter', fn);
b.addEventListener('click', fn);
b.addEventListener('focus', fn);
return () => { b.removeEventListener('pointerenter', fn); b.removeEventListener('click', fn); b.removeEventListener('focus', fn); };
});
// ---------- render loop (pauses on ml:pause and when the tab is hidden) ----------
let raf = 0, running = false, prev = performance.now();
const tick = (now) => {
if (!running) return;
const dt = Math.min(0.05, (now - prev) / 1000); prev = now;
if (!reduced) {
uniforms.uTime.value += dt;
uniforms.uMouse.value.lerp(mouseTarget, P.mouseLerp);
impulse *= VEL_DECAY;
uniforms.uVelocity.value += (impulse - uniforms.uVelocity.value) * 0.1;
}
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
const start = () => { if (running || document.hidden) return; running = true; prev = performance.now(); raf = requestAnimationFrame(tick); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
const ctx = gsap.context(() => {
if (!reduced) gsap.from('.lhd__head, .lhd__frame, .lhd__swatches', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 });
});
// ---------- knobs → params + uniforms (the renderer, textures and listeners are kept; changes are visible on the next frame) ----------
let built = false;
function build() {
P.mouseLerp = K('mouseLerp');
P.velGain = K('velGain');
P.swapDuration = K('swapDuration');
P.restStrength = K('idleWobble') ? 0.35 : 0;
uniforms.uRadius.value = K('radius');
uniforms.uSplit.value = K('split');
// pointer currently outside the frame: ease to the new resting strength so the idle toggle is visible immediately
if (built && !inside && !reduced) gsap.to(uniforms.uStrength, { value: P.restStrength, duration: 0.6, ease: 'power2.out', overwrite: true });
built = true;
}
build();
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
stop();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
frame.removeEventListener('pointermove', onMove);
frame.removeEventListener('pointerenter', onEnter);
frame.removeEventListener('pointerleave', onLeave);
swatchHandlers.forEach((fn) => fn());
ro.disconnect();
swapTween?.kill();
ctx.revert();
textures.forEach((t) => t.dispose());
geometry.dispose(); material.dispose(); renderer.dispose();
});