All components

Grid Deformation Effect

WebGL animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen hero photograph is rendered as a Three.js texture on a shader plane; moving the pointer injects velocity into a 25-cell grid DataTexture that displaces the UVs with RGB chromatic aberration, and the distortion relaxes back smoothly (0.925/frame) when the cursor stops. Styled as the landing page of Moiré, a dance and digital-image festival: the photo is monochrome, so the aberration is the only color on the page.

How it's built

Category3d-webgl
Techthree
Complexitysection
Performance costheavy
Mobile-safedesktop-first

three-js webgl shader video mouse distortion grid chromatic-aberration hover

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 as import * 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>
  • .hero is 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; }

  • .heroposition:relative; width:100%; height:100svh; overflow:hidden;
  • .hero-videoposition: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. NearestFilter is 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.25 cells; 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.

  • pixelRatio is 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. NearestFilter on the DataTexture (blocky cells) and LinearFilter on the image

texture (smooth photo) are both intentional and non-optional to the look.