Skip to content
3D

Displaced Image

A ripple radiates from the pointer while the red, green and blue channels sample at slightly different offsets — the channel separation is what makes it read as glass rather than as a wobble, because the eye interprets it as a lens. Both effects share one falloff so they land as a single gesture, and the strength eases in rather than snapping, which is the difference between a material and a bug. One textured quad in raw WebGL, no scene graph.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { DisplacedImage } from "@/components/displacedimage"; <DisplacedImage />

Customise

1

0 leaves only the channel separation.

1

0 gives a plain ripple with no lens quality.

0.08

Lower is heavier — the effect takes longer to reach full strength.

The component

components/craft/3d/DisplacedImage.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react";import { createPointerSource } from "../lib/pointerSource"; /** * An image that ripples and separates into its colour channels as the pointer * crosses it, settling back when the pointer leaves. * * Raw WebGL rather than three.js — this is one textured quad, and a scene * graph would be ~150KB to draw two triangles. That matters more here than * on a background: an image effect usually sits ON a content page, competing * with the content's own budget. * * Two effects, both driven from the same falloff so they read as one gesture: * * A ripple, displacing the texture lookup along a wave radiating from the * pointer. Sampling at a displaced coordinate rather than moving geometry * means the mesh stays a single quad — there is no subdivision to pay for and * the distortion is per-pixel smooth regardless of resolution. * * Chromatic aberration, sampling R, G and B at slightly different offsets. * This is what makes it read as glass rather than as a wobble; the eye reads * channel separation as a lens. * * The strength eases toward its target on the GPU side each frame, so the * effect has weight — snapping to full strength on pointerenter looks like a * bug rather than a material. */ const VERTEX = `attribute vec2 position;varying vec2 vUv;void main() {  vUv = position * 0.5 + 0.5;  gl_Position = vec4(position, 0.0, 1.0);}`; const FRAGMENT = `precision mediump float; uniform sampler2D uTexture;uniform vec2  uPointer;      /* 0-1, in image space */uniform float uStrength;     /* eased 0-1 */uniform float uTime;uniform float uRipple;uniform float uAberration;uniform vec2  uCover;        /* aspect-fit scale, so the image is not stretched */ varying vec2 vUv; void main() {  /* Cover-fit: scale around the centre so the texture fills the element at     its own aspect ratio instead of being squashed to the box. */  vec2 uv = (vUv - 0.5) * uCover + 0.5;   vec2 toPointer = uv - uPointer;  float distance = length(toPointer);   /* Falloff is squared so the effect is tight around the cursor rather than     smearing across the whole image. */  float falloff = smoothstep(0.55, 0.0, distance);  falloff *= falloff;   float wave = sin(distance * 26.0 - uTime * 5.0) * 0.012 * uRipple;  vec2 offset = normalize(toPointer + 1e-6) * wave * falloff * uStrength;   /* Channel separation scales with the same falloff, so it is strongest where     the ripple is and absent at the edges. */  vec2 shift = normalize(toPointer + 1e-6) * 0.006 * uAberration * falloff * uStrength;   vec2 uvR = uv + offset + shift;  vec2 uvG = uv + offset;  vec2 uvB = uv + offset - shift;   /* Clamped rather than wrapped: a displaced lookup near the border would     otherwise sample the opposite edge and produce a bright seam. */  vec4 colour = vec4(    texture2D(uTexture, clamp(uvR, 0.001, 0.999)).r,    texture2D(uTexture, clamp(uvG, 0.001, 0.999)).g,    texture2D(uTexture, clamp(uvB, 0.001, 0.999)).b,    1.0  );   gl_FragColor = colour;}`; function compile(gl: WebGLRenderingContext, type: number, source: string) {  const shader = gl.createShader(type);  if (!shader) return null;  gl.shaderSource(shader, source);  gl.compileShader(shader);  return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;} export interface DisplacedImageProps {  src: string;  alt: string;  /** Ripple amplitude. */  ripple?: number;  /** Colour-channel separation. 0 is a plain ripple. */  aberration?: number;  /** 0–1. Lower is heavier — the effect takes longer to reach full strength. */  ease?: number;  still?: boolean;  className?: string;} export function DisplacedImage({  src,  alt,  ripple = 1,  aberration = 1,  ease = 0.08,  still = false,  className = "",}: DisplacedImageProps) {  const canvasRef = useRef<HTMLCanvasElement>(null);  const hostRef = useRef<HTMLDivElement>(null);   useEffect(() => {    const canvas = canvasRef.current;    const host = hostRef.current;    if (!canvas || !host) return;     const gl = canvas.getContext("webgl", { alpha: false, antialias: false });    if (!gl) return;     const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vertex || !fragment) return;     const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vertex);    gl.attachShader(program, fragment);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     const buffer = gl.createBuffer();    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);    const position = gl.getAttribLocation(program, "position");    gl.enableVertexAttribArray(position);    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);     const uniform = (name: string) => gl.getUniformLocation(program, name);    const uPointer = uniform("uPointer");    const uStrength = uniform("uStrength");    const uTime = uniform("uTime");    const uCover = uniform("uCover");    gl.uniform1f(uniform("uRipple"), ripple);    gl.uniform1f(uniform("uAberration"), aberration);    gl.uniform1i(uniform("uTexture"), 0);     const texture = gl.createTexture();    gl.bindTexture(gl.TEXTURE_2D, texture);    // Clamped and linear: the image is not a repeating pattern, and NPOT    // textures in WebGL1 cannot use mipmaps or REPEAT at all.    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     let imageWidth = 1;    let imageHeight = 1;    let loaded = false;     // Sized per effect run, not read back off the canvas — the element keeps    // its dimensions across re-runs, and a new program's uniforms start at    // zero. Comparing against the DOM would skip the upload entirely.    let uploadedWidth = 0;    let uploadedHeight = 0;     const resize = () => {      const dpr = Math.min(devicePixelRatio || 1, 2);      const width = Math.floor(canvas.clientWidth * dpr);      const height = Math.floor(canvas.clientHeight * dpr);      if (!width || !height) return;      if (width === uploadedWidth && height === uploadedHeight) return;       uploadedWidth = width;      uploadedHeight = height;      canvas.width = width;      canvas.height = height;      gl.viewport(0, 0, width, height);       // Cover fit: whichever axis has the surplus gets scaled down in UV space.      const boxAspect = width / height;      const imageAspect = imageWidth / imageHeight;      if (boxAspect > imageAspect) {        gl.uniform2f(uCover, 1, imageAspect / boxAspect);      } else {        gl.uniform2f(uCover, boxAspect / imageAspect, 1);      }    };     /* A hover effect has nothing to respond to on a phone, so the source       sweeps the ripple across the image on its own and a tap redirects it. */    const pointer = createPointerSource(host);    let strength = pointer.automatic ? 1 : 0;    let target = pointer.automatic ? 1 : 0;    let frame = 0;     const onEnter = () => {      target = 1;    };    const onLeave = () => {      if (!pointer.automatic) target = 0;    };     const start = performance.now();     const draw = (now: number) => {      resize();      strength += (target - strength) * ease;      const local = pointer.read((now - start) / 1000);      gl.uniform2f(uPointer, local.x, local.y);      gl.uniform1f(uStrength, strength);      gl.uniform1f(uTime, (now - start) / 1000);      gl.drawArrays(gl.TRIANGLES, 0, 3);    };     const loop = (now: number) => {      draw(now);      frame = requestAnimationFrame(loop);    };     const image = new Image();    image.crossOrigin = "anonymous";    image.onload = () => {      imageWidth = image.naturalWidth;      imageHeight = image.naturalHeight;      gl.bindTexture(gl.TEXTURE_2D, texture);      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);      loaded = true;      // Force the cover-fit recompute now that the real aspect is known.      uploadedWidth = 0;      resize();       // One frame synchronously in both paths, so the image is on screen the      // moment it decodes rather than waiting on the first rAF.      draw(start);      if (!reduced) {        host.addEventListener("pointerenter", onEnter);        host.addEventListener("pointerleave", onLeave);        frame = requestAnimationFrame(loop);      }    };    image.src = src;     const observer = new ResizeObserver(() => {      if (!loaded) return;      resize();      if (reduced) draw(start);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      host.removeEventListener("pointerenter", onEnter);      host.removeEventListener("pointerleave", onLeave);      pointer.dispose();      gl.deleteProgram(program);      gl.deleteShader(vertex);      gl.deleteShader(fragment);      gl.deleteBuffer(buffer);      gl.deleteTexture(texture);      // Not calling WEBGL_lose_context: a context dropped that way cannot be      // reacquired, so the component comes back blank on any remount.    };  }, [src, ripple, aberration, ease, still]);   return (    <div ref={hostRef} className={`relative ${className}`}>      <canvas ref={canvasRef} aria-hidden className="block h-full w-full" />      {/* The canvas cannot carry alt text, so the image's meaning lives in a          visually-hidden element. A decorative treatment must not cost a          screen-reader user the content. */}      <span className="sr-only">{alt}</span>    </div>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+3.2KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Renders the image once, undistorted, and never attaches the pointer listeners. The picture is the content; the ripple is not.

Accessibility
  • The canvas cannot carry alt text, so the alt prop is rendered in a visually-hidden span alongside it.
  • Pointer listeners are the only interaction, so no tab stops are added.
  • Falls back to nothing rendered if WebGL is unavailable — pair it with a CSS background image.
Watch out
  • Cross-origin images need CORS headers or the texture upload taints the context and fails.
  • Touch devices have no hover, so the effect never triggers there — it is an enhancement, not the presentation.
  • The image is decoded twice: once by the browser, once uploaded to the GPU. Do not point it at a 4000px photo.