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. 2132 words of exact values — targets, eases, durations, triggers.
### EFFECT: Liquid Hover Distortion (WebGL)
**What it does (visual):** A 16:10 frame in the middle of a dark page shows a painted landscape — a deep indigo sky with a warm orange sun, a violet haze low on the left and a thin bright horizon line, with fine film grain over everything (the image is generated on a canvas, no file). At rest the image breathes almost imperceptibly around the centre. When the pointer moves across the frame, the image bends like the surface of water: a soft, roughly 55%-wide zone around the pointer displaces, following the pointer with a slight lag (the distortion centre eases toward the real pointer at 8% per frame), and the faster the pointer moves the deeper the warp — a slow drift barely ripples it, a fast swipe tears the gradient into liquid folds and smears the red and blue channels apart along the direction of travel. When the pointer stops, the warp calms over roughly a second. Below the frame are two pill-shaped swatches ("01 Dusk Harbour", "02 Verdigris"); hovering (or tapping) the second one melts the image into a teal/lime composition: instead of a crossfade, the new image eats through the old along a noisy liquid edge over 1.2s while both images wobble, then settles perfectly still. Hovering the first swatch melts back. A mono caption in the frame's corner updates with the image name and "02 / 02".
**Tech stack:**
- Three.js r160+ (`WebGLRenderer`, `OrthographicCamera`, `PlaneGeometry`, `ShaderMaterial`, `CanvasTexture`), GSAP 3.13+ for the uniform tweens, Canvas 2D for the artwork.
- npm: `npm i three gsap` → `import * as THREE from 'three'; import gsap from 'gsap';`
- CDN: `<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js"}}</script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="lhd">
<header class="lhd__head">
<div><p class="lhd__eyebrow">WebGL · ShaderMaterial</p><h1 class="lhd__title">Surface tension.</h1></div>
<p class="lhd__hint">Move across the image · hover a swatch to change it</p>
</header>
<div class="lhd__frame">
<canvas class="lhd__canvas" role="img" aria-label="Interactive liquid distortion of a painted landscape"></canvas>
<p class="lhd__caption"><span class="lhd__caption-name">Dusk Harbour</span><span class="lhd__caption-index">01 / 02</span></p>
</div>
<nav class="lhd__swatches" aria-label="Choose an image">
<button class="lhd__swatch is-active" data-index="0" aria-pressed="true"><canvas class="lhd__swatch-art" width="240" height="150"></canvas><span class="lhd__swatch-text"><span class="lhd__swatch-num">01</span><span class="lhd__swatch-name">Dusk Harbour</span></span></button>
<button class="lhd__swatch" data-index="1" aria-pressed="false"><canvas class="lhd__swatch-art" width="240" height="150"></canvas><span class="lhd__swatch-text"><span class="lhd__swatch-num">02</span><span class="lhd__swatch-name">Verdigris</span></span></button>
</nav>
</section>
```
**CSS requirements:**
- `.lhd`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: 1.5rem; padding: 2.25rem 3rem;`
- `.lhd__frame`: `position: relative; width: min(100%, calc((100vh - 250px) * 1.6)); aspect-ratio: 16 / 10; margin: auto; border-radius: 12px; overflow: hidden; background: var(--surface, #121212); touch-action: pan-y;` (pan-y keeps vertical page scroll working on touch while horizontal drags drive the shader).
- `.lhd__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.lhd__caption`: absolute bottom-left/right, mono 11px uppercase, `letter-spacing: .1em; color: rgba(255,255,255,.85); pointer-events: none;`
- `.lhd__swatch`: pill button, `display: flex; gap: .9rem; padding: .5rem .9rem .5rem .5rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted);` — `.is-active`/hover: `color: var(--text)`, number in `var(--accent)`. Swatch canvas `64×40px, border-radius: 999px; object-fit: cover`.
- Title: display serif 300, `clamp(2rem, 4.2vw, 3.6rem); letter-spacing: -0.04em`.
- Artwork: paint two 1024×640 canvases. Base `linearGradient(0,0 → 0.4w,h)` through the palette's three stops; then with `globalCompositeOperation = 'screen'` three radial orbs `(x·w, y·h, radius r·w)` from colour → transparent; back to `source-over`, a 6%-tall horizon highlight band; finally per-pixel grain `±10` on RGB via `getImageData`.
- Dusk Harbour: base `#0b1026 → #2a1740 → #0a0a0a`; orbs `(0.68, 0.38, 0.34, rgba(255,140,80,.95))`, `(0.3, 0.72, 0.42, rgba(180,60,140,.6))`, `(0.1, 0.18, 0.3, rgba(60,140,210,.5))`; horizon at `y = 0.62`, `rgba(255,214,170,.42)`.
- Verdigris: base `#061a18 → #0e3b35 → #0a0a0a`; orbs `(0.26, 0.3, 0.3, rgba(212,255,58,.7))`, `(0.76, 0.66, 0.36, rgba(40,160,150,.75))`, `(0.86, 0.14, 0.22, rgba(240,200,120,.4))`; horizon at `0.56`, `rgba(212,255,58,.22)`.
- Draw each big canvas scaled into its swatch canvas with `drawImage(src, 0, 0, 240, 150)`.
**Animation spec (exact values):**
1. Renderer: `new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' })`, `setPixelRatio(Math.min(devicePixelRatio, 2))` (1 when embedded as a preview), `setSize(frame.clientWidth, frame.clientHeight, false)` from a `ResizeObserver` on the frame, which also sets `uAspect = w / h`. `OrthographicCamera(-1, 1, 1, -1, 0, 1)`, `PlaneGeometry(2, 2)`, vertex shader writes `gl_Position = vec4(position.xy, 0.0, 1.0)` and passes `uv`.
2. Textures: `new THREE.CanvasTexture(canvas)` with `generateMipmaps = false; minFilter = magFilter = LinearFilter; wrapS = wrapT = MirroredRepeatWrapping` (displaced UVs that leave 0–1 mirror instead of streaking).
3. Uniforms: `uTex1` (current), `uTex2` (incoming), `uTime: 0`, `uProgress: 0`, `uVelocity: 0`, `uStrength: 1`, `uAspect: 1.6`, `uMouse: Vector2(0.5, 0.5)`. `ShaderMaterial({ uniforms, vertexShader, fragmentShader, depthTest: false, depthWrite: false })`.
4. Pointer: on `pointermove` over the frame compute `x = (clientX - left) / width`, `y = 1 - (clientY - top) / height`; `impulse = min(1.5, impulse + hypot(dx, dy) * 12)` (distance since the previous event); `mouseTarget.set(x, y)`.
5. Per frame (rAF): `uTime += dt`; `uMouse.lerp(mouseTarget, 0.08)`; `impulse *= 0.93`; `uVelocity += (impulse - uVelocity) * 0.1`; `renderer.render(scene, camera)`.
6. Enter/leave the frame: `gsap.to(uStrength, { value: 1, duration: 0.6, ease: 'power2.out' })` on enter; `{ value: 0.35, duration: 1.2, ease: 'power2.out' }` on leave (a faint idle wobble remains).
7. Swap to image `i` (on swatch `pointerenter`, `click` and `focus`): set `uTex2 = textures[i]`, then `gsap.fromTo(uProgress, { value: 0 }, { value: 1, duration: 1.2, ease: 'power3.inOut', onComplete: () => { uTex1 = textures[i]; uProgress = 0; } })`. If the user returns to the outgoing image mid-swap, kill the tween and `gsap.to(uProgress, { value: 0, duration: 1.2 * uProgress, ease: 'power3.inOut' })` instead. Update the caption and `aria-pressed`.
8. Fragment shader (complete):
```glsl
precision highp float;
uniform sampler2D uTex1; uniform sampler2D uTex2;
uniform float uTime, uProgress, uVelocity, uStrength, uAspect;
uniform vec2 uMouse;
varying vec2 vUv;
vec2 mod289(vec2 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x * 34.0) + 1.0) * x); }
vec4 permute(vec4 x){ return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec2 v){ // 2D simplex
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float snoise(vec3 v){ // 3D simplex
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g; vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx; vec3 x2 = x0 - i2 + C.yyy; vec3 x3 = x0 - D.yyy; i = mod289(i);
vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857; vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x), p1 = vec3(a0.zw, h.y), p2 = vec3(a1.xy, h.z), p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
void main(){
vec2 uv = vUv; vec2 asp = vec2(uAspect, 1.0);
float d = distance(uv * asp, uMouse * asp);
float falloff = smoothstep(0.55, 0.0, d); // 1 at the pointer → 0 at radius .55
float n1 = snoise(vec3(uv * 3.0, uTime * 0.25));
float n2 = snoise(vec3(uv * 3.0 + 17.0, uTime * 0.25));
float amp = uStrength * (0.02 + uVelocity * 0.09) * falloff; // still pointer: 0.02, fast swipe: up to ~0.155
float wave = sin(uProgress * 3.14159265); // swap wobble 0 → 1 → 0
vec2 duv = uv + vec2(n1, n2) * (amp + wave * 0.06);
float tn = snoise(uv * 4.0 + vec2(0.0, uTime * 0.1)) * 0.5 + 0.5;
float pp = uProgress * 1.3 - 0.15;
float m = 1.0 - smoothstep(pp - 0.15, pp + 0.15, tn); // liquid mask: 0 everywhere at p=0, 1 everywhere at p=1
float split = uVelocity * 0.012 * falloff + wave * 0.008;
vec2 dir = normalize((uv - uMouse) * asp + vec2(1e-4)) * split;
vec3 c1 = vec3(texture2D(uTex1, duv + dir).r, texture2D(uTex1, duv).g, texture2D(uTex1, duv - dir).b);
vec3 c2 = vec3(texture2D(uTex2, duv + dir).r, texture2D(uTex2, duv).g, texture2D(uTex2, duv - dir).b);
vec3 col = mix(c1, c2, m);
float vig = smoothstep(1.1, 0.35, length((uv - 0.5) * vec2(1.15, 1.0)));
col *= mix(0.78, 1.0, vig);
gl_FragColor = vec4(col, 1.0);
}
```
9. Decorative entrance: `gsap.from('.lhd__head, .lhd__frame, .lhd__swatches', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 })`.
**Interaction:** Pointer movement over the frame drives `uMouse` (lagged 0.08) and `uVelocity` (impulse ×12 per uv unit, decays ×0.93/frame, smoothed 0.1) — the image warps and chromatically splits in proportion to speed and settles in ~1s. Entering the frame raises `uStrength` to 1 in 0.6s, leaving lowers it to 0.35 in 1.2s. Hover/click/focus on a swatch triggers the 1.2s `power3.inOut` liquid swap; hovering back mid-swap reverses it.
**Responsive behavior:** The frame is fluid (`min(100%, (100vh - 250px) * 1.6)`) and stays 16:10 at every width; at 375px it is ~300×188px. Touch is **degraded** but live: pointer events fire from `touchmove`, so dragging a finger across the frame drives `uMouse`/`uVelocity` (with `touch-action: pan-y` so vertical scrolling still works); swatches respond to tap via `click`. Under 768px the header stacks and swatch names collapse to numbers. No `gsap.matchMedia()` branches are required because the shader is resolution-independent; DPR is capped at 2 (1 in preview embeds).
**Accessibility:** `prefers-reduced-motion: reduce` → `uStrength = 0` and `uTime` is frozen (no displacement, no idle wobble), `uMouse` is set directly without lag, and swaps are instant (`uTex1` replaced, `uProgress = 0`). The canvas has `role="img"` with an `aria-label`; swatches are real `<button>`s with `aria-pressed`, respond to keyboard focus and show a `2px solid var(--accent)` focus ring; the caption text mirrors the current image name.
**Performance rules:** One draw call, six texture lookups per fragment, no post-processing. DPR `Math.min(devicePixelRatio, 2)`, halved for previews. The rAF loop stops on the host `ml:pause` event and on `visibilitychange` (hidden) and restarts on `ml:resume`/visible. `ResizeObserver` instead of per-frame size reads. On unmount: cancel the rAF, remove listeners, disconnect the observer, kill the swap tween, `texture.dispose()`, `geometry.dispose()`, `material.dispose()`, `renderer.dispose()`.
**Customization knobs:**
- `MOUSE_LERP` (0.08) — lag of the distortion centre.
- `VEL_GAIN` (12) / `VEL_DECAY` (0.93) — how hard a swipe hits and how long it lingers.
- `0.55` falloff radius and `0.02 / 0.09` displacement amplitudes in the shader.
- `0.012` RGB split strength; `0.06` swap wobble amplitude.
- `SWAP_DURATION` (1.2s) and `uv * 4.0` mask frequency (lower = larger liquid blobs).
**Acceptance criteria:**
- [ ] At rest the image is sharp with only a faint slow wobble near the centre.
- [ ] Moving the pointer bends the image around a lagging centre; faster movement = deeper warp and visible red/blue fringing along the motion direction.
- [ ] The warp decays within ~1s of the pointer stopping.
- [ ] Hovering the second swatch melts to the second image along a noisy edge in 1.2s; both images wobble during the swap and settle still afterwards; caption and `aria-pressed` update.
- [ ] Reduced-motion users see static images and instant swaps; the rAF loop halts when the tab is hidden; no console errors or WebGL warnings. 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`, 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, and for `prefers-reduced-motion` set `uStrength = 0` with instant swaps. Keep my layout, fonts and colours. 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';
const MOUSE_LERP = 0.08; // how fast the shader's pointer chases the real one
const VEL_DECAY = 0.93; // per-frame decay of the pointer-speed impulse
const VEL_GAIN = 12; // uv distance per move event → impulse
const SWAP_DURATION = 1.2; // seconds, power3.inOut
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 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(0.55, 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 * 0.012 * falloff + wave * 0.008;
vec2 dir = normalize((uv - uMouse) * asp + vec2(1e-4)) * split;
vec3 c1 = vec3(texture2D(uTex1, duv + dir).r, texture2D(uTex1, duv).g, texture2D(uTex1, duv - dir).b);
vec3 c2 = vec3(texture2D(uTex2, duv + dir).r, texture2D(uTex2, duv).g, texture2D(uTex2, duv - dir).b);
vec3 col = mix(c1, c2, m);
// 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) },
};
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;
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) * VEL_GAIN);
last.set(x, y); hasLast = true;
mouseTarget.set(x, y);
if (reduced) uniforms.uMouse.value.copy(mouseTarget);
};
const onEnter = () => { hasLast = false; if (!reduced) gsap.to(uniforms.uStrength, { value: 1, duration: 0.6, ease: 'power2.out', overwrite: true }); };
const onLeave = () => { hasLast = false; if (!reduced) gsap.to(uniforms.uStrength, { value: 0.35, 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: SWAP_DURATION * 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: SWAP_DURATION, 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, MOUSE_LERP);
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 });
});
window.MotionLab?.onCleanup(() => {
stop();
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();
});