Grid Deformation Hover Effect (WebGL mouse-velocity image distortion)
Goal
Build a full-screen hero that shows a single background image, but the image is not a plain <img> — it is rendered onto a Three.js shader plane. Moving the pointer over the hero injects velocity into a coarse grid of cells stored in a DataTexture; each cell's accumulated push displaces the image UVs locally, dragging the pixels in the direction of the swipe, with a subtle RGB chromatic-aberration split along the displacement. When the cursor stops, the whole field relaxes back to rest smoothly (a per-cell decay of 0.925/frame), so the image un-warps on its own. The star of this piece is the grid-based UV displacement shader + the mouse-velocity physics — there is no DOM animation and nothing scrolls.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by Vite.
three(npm) is the only JS dependency. Import it asimport * as THREE from "three";.- No GSAP, no ScrollTrigger, no SplitText, no CustomEase, no Lenis. All motion is a continuous
renderer.setAnimationLoop(...) that re-integrates the grid every frame and re-renders. Do not reach for any animation library — the "easing" is the numeric relaxation + velocity-decay constants, applied by hand.
Layout / HTML
Minimal DOM. One section, one <img> inside it. JS hides the <img> and appends the WebGL <canvas> on top.
<section class="hero">
<img class="hero-video" src="/assets/hero-portrait.jpg" alt="" crossorigin="anonymous" />
</section>
<script type="module" src="./script.js"></script>
.herois the full-viewport container; the renderer's<canvas>is appended into it by JS
(JS also adds the class hero-canvas to that canvas).
.hero-video(the<img>) is the picture source. It is used two ways: (1) its file is loaded a
second time as a THREE.Texture to feed the shader, and (2) its naturalWidth/naturalHeight provide the aspect used for cover-fit. JS sets its style.opacity = "0" so the raw DOM image is invisible and only the WebGL canvas shows. Keep crossorigin="anonymous" so the texture is not tainted.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }
.hero—position:relative; width:100%; height:100svh; overflow:hidden;.hero-video—position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover;
(it is hidden by JS, but styled to cover in case the canvas is delayed).
.hero-canvas(added to the<canvas>by JS) —
position:absolute; top:0; left:0; width:100%; height:100%; z-index:1; so the canvas sits above the (invisible) image and fills the hero.
No fonts, no text, no other DOM. The entire visual is the distorted picture.
The star effect — Three.js grid-displacement shader (be exact)
This is a near-verbatim port; reproduce the constants, the DataTexture bookkeeping, the shader, and the per-frame integration exactly. All of it lives in the module script.js.
Config constants (use these exact values)
const GRID_SIZE = 25; // cells along the SHORT axis of the viewport
const MOUSE_RADIUS = 0.25; // influence radius as a fraction of GRID_SIZE
const STRENGTH = 0.1; // push strength (multiplied by 100 in the integrator → 10)
const RELAXATION = 0.925; // per-frame decay of each cell back toward 0 (the "un-warp")
const DISPLACEMENT = 0.015; // max UV shift the grid value maps to, in the shader
const ABERRATION = 0.15; // RGB split as a fraction of the displacement, in the shader
DISPLACEMENT and ABERRATION are inlined into the fragment-shader source as literals (via template strings), so 0.015 and 0.15 must appear in the GLSL.
Renderer, scene & camera
hero = document.querySelector(".hero"),video = document.querySelector(".hero-video").- Cache
width = hero.offsetWidth,height = hero.offsetHeight. const scene = new THREE.Scene();- Orthographic camera filling exactly a 2×2 world:
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.domElement.classList.add("hero-canvas"); hero.appendChild(renderer.domElement);
Image texture
const videoTexture = new THREE.TextureLoader().load("/assets/hero-portrait.jpg");
videoTexture.minFilter = videoTexture.magFilter = THREE.LinearFilter;
videoTexture.generateMipmaps = false;
video.style.opacity = "0";
- Load the same file as the
<img>src. Do not set a color space (leave the default) — no
extra tone/gamma conversion is applied.
The DataTexture grid (the deformation field)
A tiny RGBA float texture, one texel per grid cell. R holds horizontal push, G holds vertical push; B/A unused. The grid is GRID_SIZE cells on the short axis and proportionally more on the long axis, so cells stay ~square regardless of viewport aspect:
let gridX, gridY;
function createDataTexture() {
const aspect = width / height;
gridX = aspect >= 1 ? Math.round(GRID_SIZE * aspect) : GRID_SIZE;
gridY = aspect >= 1 ? GRID_SIZE : Math.round(GRID_SIZE / aspect);
const data = new Float32Array(gridX * gridY * 4); // all zeros = flat/rest
const texture = new THREE.DataTexture(
data, gridX, gridY, THREE.RGBAFormat, THREE.FloatType,
);
texture.magFilter = texture.minFilter = THREE.NearestFilter; // hard cells, no smoothing
texture.needsUpdate = true;
return texture;
}
let dataTexture = createDataTexture();
- e.g. a 16:9 viewport →
gridX ≈ 44,gridY = 25.NearestFilteris important: it keeps each
cell a crisp block so the displacement reads as a coarse grid, not a smooth blob.
Cover-fit plane
The plane is sized so the image covers the 2×2 camera frustum (like CSS object-fit: cover) — the overflowing dimension is clipped by the ortho camera:
function getCoverScale() {
const videoAspect = (video.naturalWidth || 16) / (video.naturalHeight || 9);
const containerAspect = width / height;
const scaleX = containerAspect < videoAspect ? videoAspect / containerAspect : 1;
const scaleY = containerAspect > videoAspect ? containerAspect / videoAspect : 1;
return [2 * scaleX, 2 * scaleY]; // base size 2×2, one axis scaled up to cover
}
Before the image has decoded, naturalWidth/Height are 0, so it falls back to 16/9.
Shader material + mesh
const material = new THREE.ShaderMaterial({
uniforms: {
uTexture: { value: videoTexture },
uDataTexture: { value: dataTexture },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D uTexture;
uniform sampler2D uDataTexture;
varying vec2 vUv;
void main() {
vec4 offset = texture2D(uDataTexture, vUv);
vec2 shift = 0.015 * offset.rg; // DISPLACEMENT * grid value (R=x, G=y)
vec2 split = shift * 0.15; // ABERRATION * shift → RGB fan-out
// sample each channel at a slightly different offset → chromatic aberration
float r = texture2D(uTexture, vUv - shift + split).r;
float g = texture2D(uTexture, vUv - shift).g;
float b = texture2D(uTexture, vUv - shift - split).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
`,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(...getCoverScale()), material);
scene.add(mesh);
- At rest the DataTexture is all zeros →
shift = split = 0→ the image renders undistorted. - Where a cell has been pushed, all three channels sample from
vUv - shift, but R is pulled an
extra +split and B an extra -split, so edges fringe red/blue along the push direction.
- Rebuild the geometry once the image finishes decoding so the real aspect is used
(the original listens for the loadeddata event; using the <img>'s load event is equivalent): ``js video.addEventListener("loadeddata", () => { mesh.geometry.dispose(); mesh.geometry = new THREE.PlaneGeometry(...getCoverScale()); }); ``
Mouse input → velocity
Track normalized cursor position (0–1 within the hero) and its per-event velocity:
const mouse = { x: 0, y: 0, prevX: 0, prevY: 0, vX: 0, vY: 0 };
hero.addEventListener("mousemove", (event) => {
const rect = hero.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
mouse.vX = x - mouse.prevX; // velocity ≈ movement delta
mouse.vY = y - mouse.prevY;
mouse.prevX = mouse.x;
mouse.prevY = mouse.y;
mouse.x = x;
mouse.y = y;
});
Keep this exact bookkeeping order (vX is computed against the *previous* stored prevX, then prevX is advanced to the last mouse.x).
Per-frame integration (the physics — be exact)
Every frame, first relax the whole field toward zero, then add velocity to every cell inside the cursor's influence radius, weighted by an inverse-distance falloff:
function updateDataTexture() {
const data = dataTexture.image.data;
// 1) relaxation: decay R and G of every cell toward 0 (un-warp)
for (let i = 0; i < data.length; i += 4) {
data[i] *= RELAXATION; // 0.925
data[i + 1] *= RELAXATION;
}
// 2) cursor position in grid space; y is flipped (texture origin bottom-left)
const gridMouseX = gridX * mouse.x;
const gridMouseY = gridY * (1 - mouse.y);
const maxDist = GRID_SIZE * MOUSE_RADIUS; // 25 * 0.25 = 6.25 cells
for (let i = 0; i < gridX; i++) {
for (let j = 0; j < gridY; j++) {
const distanceSq = (gridMouseX - i) ** 2 + (gridMouseY - j) ** 2;
if (distanceSq >= maxDist * maxDist) continue; // outside brush → skip
const index = 4 * (i + gridX * j);
const power = Math.min(10, maxDist / Math.sqrt(distanceSq)); // ↑ near cursor, capped at 10
data[index] += STRENGTH * 100 * mouse.vX * power; // 10 * vX * power → R (horizontal)
data[index + 1] -= STRENGTH * 100 * mouse.vY * power; // -(10 * vY * power) → G (vertical)
}
}
// 3) velocity itself bleeds off so a flick fades even between mousemove events
mouse.vX *= 0.9;
mouse.vY *= 0.9;
dataTexture.needsUpdate = true;
}
- The brush is a soft disc of radius
6.25cells; cells right under the cursor get the strongest
push (power up to 10), cells at the edge get almost none.
- Two decays stack to make the "spring-back" feel: the stored field decays
0.925/frame and the
input velocity decays 0.9/frame. Together, a fast swipe drags a smear that snaps back over roughly half a second after the cursor stops.
Resize
window.addEventListener("resize", () => {
width = hero.offsetWidth;
height = hero.offsetHeight;
mesh.geometry.dispose();
mesh.geometry = new THREE.PlaneGeometry(...getCoverScale());
dataTexture.dispose();
dataTexture = createDataTexture(); // grid re-derived from new aspect
material.uniforms.uDataTexture.value = dataTexture;
renderer.setSize(width, height);
});
Render loop
renderer.setAnimationLoop(() => {
updateDataTexture();
renderer.render(scene, camera);
});
No requestAnimationFrame bookkeeping, no delta-time — the constants are tuned per-frame at ~60fps.
Assets / images
- 1 full-bleed background image — a motion-blurred editorial/fashion photograph (
hero-image.jpg),
roughly 16:9 landscape, used as the single cover-fit texture for the shader plane. The real subject is a cropped human figure caught mid-movement: a bare shoulder and arm (warm skin tone) draped in layers of sheer, gauzy fabric — cream/off-white cloth below and translucent grey tulle sweeping across the upper body — against a soft, out-of-focus background. Dominant colors are muted and desaturated: cool greys and slate, cream/ivory whites, and warm skin beige, with a visible film-grain / soft-focus quality throughout. This kind of imagery works well because the built-in blur and grain, combined with the high-contrast fabric folds and skin edges, make the chromatic fringing and local UV smear read clearly. It is cover-fit and cropped, so exact aspect is not critical (anything from ~3:4 portrait to ~16:9 landscape is fine). Any strong, detailed photo will show the effect. No logos, no text baked in.
Behavior notes
- Desktop, pointer-driven. With no cursor movement the hero shows the plain, undistorted image;
the warp exists only while (and shortly after) the pointer sweeps across it. There is no autoplay, no idle animation, nothing scroll-triggered.
- The animation loop runs continuously (forever), but at rest it re-renders an all-zero field, so
the picture is static until the mouse moves.
- The grid resolution scales with viewport aspect; the influence radius and strengths are in
grid-cell units, so the effect feels consistent across sizes.
pixelRatiois capped at 2 for performance. There is no reduced-motion branch and no touch
handler in the original — on touch devices the image simply sits undistorted.
- Requires WebGL.
NearestFilteron the DataTexture (blocky cells) andLinearFilteron the image
texture (smooth photo) are both intentional and non-optional to the look.