Rejouice — loading0

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…
WebGL hover displacement — pointer warps the textureOpen standalone ↗

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

next-tailwind-motion
"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.