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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { DisplacedImage } from "@/components/displacedimage"; <DisplacedImage />Customise
0 leaves only the channel separation.
0 gives a plain ripple with no lens quality.
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.