Image Effects
ImageDistort
The picture bulges under the pointer and smears with its speed, with a little colour split. WebGL, no three.js. The plain picture stays underneath for screen readers and for devices without WebGL.
Download
- React
- Tailwind CSS v4
- motion
components/ui/image-distort.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type ImageDistortProps = {
src: string;
alt: string;
/** How hard the picture bends, 0 to 1. */
strength?: number;
/** Size of the bent area, as a part of the picture height. */
radius?: number;
/** Set the size here. */
className?: string;
};
const VERTEX = `attribute vec2 p;varying vec2 v;void main(){v=p*.5+.5;gl_Position=vec4(p,0.,1.);}`;
const FRAGMENT = `precision highp float;
varying vec2 v;
uniform sampler2D t;
uniform vec2 fit,m,d;
uniform float s,r,box;
vec4 pick(vec2 p){return texture2D(t,(p-.5)*fit+.5);}
void main(){
float f=(1.-smoothstep(0.,r,length((v-m)*vec2(box,1.))))*s;
vec2 uv=v-(v-m)*f*.35-d*f*2.;
vec2 split=d*f*.6+(v-m)*f*.015;
gl_FragColor=vec4(pick(uv+split).r,pick(uv).g,pick(uv-split).b,1.);
}`;
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* ImageDistort: the picture bulges under the pointer and smears with its speed, with a little colour split. The plain <img> stays underneath. */
export function ImageDistort({ src, alt, strength = 1, radius = 0.4, className = "aspect-video" }: ImageDistortProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const box = boxRef.current;
if (!box || reduce) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
gl.uniform1f(at("r"), radius);
const state = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, dx: 0, dy: 0, s: 0, ts: 0, inside: false };
let raf = 0;
let last = 0;
let ready = false;
const image = new Image();
image.crossOrigin = "anonymous";
const draw = () => {
gl.uniform2f(at("m"), state.x, state.y);
gl.uniform2f(at("d"), state.dx, state.dy);
gl.uniform1f(at("s"), state.s * strength);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
if (!ready) return;
const shape = width / height / (image.naturalWidth / image.naturalHeight);
gl.uniform2f(at("fit"), Math.min(1 / shape, 1), Math.min(shape, 1));
gl.uniform1f(at("box"), width / height);
draw();
};
const frame = (now: number) => {
const step = Math.min((now - last) / 16.7, 3);
last = now;
const ease = 1 - Math.pow(0.82, step);
state.x += (state.tx - state.x) * ease;
state.y += (state.ty - state.y) * ease;
state.s += (state.ts - state.s) * (1 - Math.pow(0.88, step));
const decay = Math.pow(0.9, step);
state.dx *= decay;
state.dy *= decay;
draw();
const busy = Math.abs(state.ts - state.s) > 0.002 || Math.hypot(state.dx, state.dy) > 0.0004 || Math.hypot(state.tx - state.x, state.ty - state.y) > 0.001;
raf = busy ? requestAnimationFrame(frame) : 0;
};
const wake = () => {
if (!ready || raf) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const place = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = 1 - (e.clientY - r.top) / r.height;
if (state.inside) {
state.dx = Math.max(-0.08, Math.min(0.08, state.dx + (x - state.tx) * 0.5));
state.dy = Math.max(-0.08, Math.min(0.08, state.dy + (y - state.ty) * 0.5));
} else {
state.x = x;
state.y = y;
state.inside = true;
}
state.tx = x;
state.ty = y;
state.ts = 1;
wake();
};
const leave = () => {
state.ts = 0;
state.inside = false;
wake();
};
image.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, wrap, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
ready = true;
fit();
box.append(canvas);
};
image.src = src;
const sized = new ResizeObserver(fit);
sized.observe(box);
const lost = () => canvas.remove();
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointerdown", place);
box.addEventListener("pointermove", place);
box.addEventListener("pointerleave", leave);
box.addEventListener("pointercancel", leave);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
image.onload = null;
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointerdown", place);
box.removeEventListener("pointermove", place);
box.removeEventListener("pointerleave", leave);
box.removeEventListener("pointercancel", leave);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
};
}, [src, strength, radius, reduce]);
return (
<div ref={boxRef} className={`relative touch-pan-y overflow-hidden ${className}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className="absolute inset-0 size-full object-cover" />
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/image-distort.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/image-distort-demo.tsx
import { ImageDistort } from "@/components/ui/image-distort"; export default function ImageDistortExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <ImageDistort src="/demo/dusk.jpg" alt="Hills at dusk under a pale sun" className="aspect-video w-full max-w-[520px] rounded-2xl" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. From another site it needs CORS headers, or the plain picture shows with no effect. |
| alt | string | — | Text for screen readers. |
| strength | number | 1 | How hard the picture bends, 0 to 1. |
| radius | number | 0.4 | Size of the bent area, as a part of the picture height. |
| className | string | "aspect-video" | Set the size here. |