WebGL hover displacement
An image rendered to a shader plane that warps and fringes under the pointer, then eases back.
Grade: CRuntimes: threeStatus: live
Loading demo…
Intent
The one visual class transforms cannot produce. Everything else in this system moves whole elements; this moves pixels, sampling the texture through a noise field and offsetting each colour channel so the edges fringe. It is the most expensive thing here, and it should be used once, if at all.
Implementation
React Three Fiber v9, which requires React >=19 <19.3 — this project pins React exactly for that reason.
Dependencies: three@^0.185, @react-three/fiber@^9
"use client";
import { Canvas, useFrame, useLoader } from "@react-three/fiber";
import { useRef, useState } from "react";
import * as THREE from "three";
const FRAGMENT = `
uniform sampler2D uTexture;
uniform float uProgress;
uniform float uTime;
varying vec2 vUv;
float noise(vec2 p) {
return sin(p.x * 9.0 + uTime) * sin(p.y * 11.0 - uTime * 0.7);
}
void main() {
float n = noise(vUv) * 0.03 * uProgress;
vec2 uv = vUv + vec2(n, n * 0.6);
float r = texture2D(uTexture, uv + vec2(0.006 * uProgress, 0.0)).r;
float g = texture2D(uTexture, uv).g;
float b = texture2D(uTexture, uv - vec2(0.006 * uProgress, 0.0)).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
`;
function Plane({ src, hovered }: { src: string; hovered: boolean }) {
const texture = useLoader(THREE.TextureLoader, src);
const material = useRef<THREE.ShaderMaterial>(null);
useFrame((state, delta) => {
const u = material.current?.uniforms;
if (!u) return;
u.uTime.value = state.clock.elapsedTime;
u.uProgress.value = THREE.MathUtils.damp(u.uProgress.value, hovered ? 1 : 0, 4, delta);
});
return (
<mesh>
<planeGeometry args={[3.2, 2, 32, 32]} />
<shaderMaterial ref={material} fragmentShader={FRAGMENT} uniforms={{
uTexture: { value: texture }, uProgress: { value: 0 }, uTime: { value: 0 },
}} />
</mesh>
);
}Use it when
- A single hero or featured image where the extra weight buys a moment nothing else can.
- When the image is strong enough that distorting it is a flourish rather than a rescue.
Avoid it when
- Anywhere a CSS filter or a transform would read almost the same. That is nearly everywhere.
- Grids and lists — one WebGL context per card exhausts the browser's context limit.
- Battery-sensitive contexts. A live render loop is a real power cost.
Accessibility
- Under reduced motion render the plain image — this is decoration, and a warping picture is exactly what the preference is about.
- A canvas has no accessible content. Put the image's meaning in an `aria-label` or adjacent text.
- The effect is pointer-only, so it must never be the sole route to anything.
Performance
- One WebGL context, and only inside an isolated demo. Browsers cap concurrent contexts, so a grid of these fails outright.
- Damp the progress uniform toward its target rather than snapping — it costs nothing and removes the mechanical feel.
- Cap `dpr` at 2. Rendering a shader at native density on a high-DPI display is mostly wasted.
- This is the heaviest pattern in the corpus and grades accordingly.
Knobs
- displacement
- Peak texture offset at full hover. Default: 0.03
- fringe
- Per-channel offset producing the RGB split. Default: 0.006
- damping
- How quickly progress eases toward its target. Default: 4
Composition
Pairs with
Conflicts with
None.
Instruction for Claude Code
Build a WebGL hover displacement: render the image to a shader plane in react-three-fiber, and in the fragment shader sample the texture through a procedural noise offset scaled by a hover progress uniform, offsetting the red and blue channels by about 0.006 to fringe the edges. Damp progress toward its target with MathUtils.damp rather than snapping. Cap dpr at 2, use exactly one WebGL context, and never put this in a grid — browsers limit concurrent contexts. Under prefers-reduced-motion render the plain img instead, and give the canvas an aria-label because a canvas has no accessible content.