Image Effects
DepthParallax
One flat picture plus a grey depth map looks like a small window into a scene. Near parts travel further than far parts as the pointer moves. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/depth-parallax.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type DepthParallaxProps = {
src: string;
/** Grey picture of the same shape. White is near, black is far. Blur it a little. */
depth: string;
alt: string;
/** How far near parts travel, as a part of the picture width. */
strength?: 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.);}`;
// The depth is read three times, each from the last place found, so near edges do not tear.
const FRAGMENT = `precision highp float;
varying vec2 v;
uniform sampler2D t,z;
uniform vec2 fit,m;
void main(){
vec2 start=(v-.5)*fit+.5;
vec2 p=start;
for(int i=0;i<3;i++)p=start-m*(texture2D(z,p).r-.45);
gl_FragColor=texture2D(t,p);
}`;
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;
}
function load(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = reject;
image.src = url;
});
}
/* DepthParallax: one flat picture plus a depth map looks like a small window into a scene. Near parts move more than far parts as the pointer moves. */
export function DepthParallax({ src, depth, alt, strength = 0.04, className = "aspect-video" }: DepthParallaxProps) {
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.uniform1i(at("t"), 0);
gl.uniform1i(at("z"), 1);
let raf = 0;
let last = 0;
let ratio = 1;
let ready = false;
let cancelled = false;
let [x, y, tx, ty] = [0, 0, 0, 0];
const draw = () => {
gl.uniform2f(at("m"), x * strength, y * 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 / ratio;
const spare = 1 - strength * 1.6;
gl.uniform2f(at("fit"), Math.min(1 / shape, 1) * spare, Math.min(shape, 1) * spare);
draw();
};
const frame = (now: number) => {
const ease = 1 - Math.pow(0.9, Math.min((now - last) / 16.7, 3));
last = now;
x += (tx - x) * ease;
y += (ty - y) * ease;
draw();
raf = Math.hypot(tx - x, ty - y) > 0.002 ? requestAnimationFrame(frame) : 0;
};
const wake = () => {
if (!ready || raf) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const place = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
tx = ((e.clientX - r.left) / r.width) * 2 - 1;
ty = 1 - ((e.clientY - r.top) / r.height) * 2;
wake();
};
const leave = () => {
tx = 0;
ty = 0;
wake();
};
Promise.all([load(src), load(depth)])
.then(([picture, map]) => {
if (cancelled) return;
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
[picture, map].forEach((image, unit) => {
gl.activeTexture(gl.TEXTURE0 + unit);
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);
});
ratio = picture.naturalWidth / picture.naturalHeight;
ready = true;
fit();
box.append(canvas);
})
.catch(() => canvas.remove());
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 () => {
cancelled = true;
cancelAnimationFrame(raf);
sized.disconnect();
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, depth, strength, 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/depth-parallax.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/depth-parallax-demo.tsx
import { DepthParallax } from "@/components/ui/depth-parallax"; export default function DepthParallaxExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <DepthParallax src="/demo/dusk.jpg" depth="/demo/dusk-depth.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. |
| depth | string | โ | Grey picture of the same shape. White is near, black is far. Blur it a little. |
| alt | string | โ | Text for screen readers. |
| strength | number | 0.04 | How far near parts travel, as a part of the picture width. |
| className | string | "aspect-video" | Set the size here. |