Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Depth map shader" for my site using the Enrichr Library element at https://enrichr.dev/elements/depth-map-shader.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/depth-map-shader.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- strength: 0.05
- smoothing: 0.1
- image:
- colorA: #2f7d5b
- colorB: #c6f26b
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/depth-map-shader.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-dgl"><canvas class="ef-dgl__c" role="img" aria-label="An image whose layers shift with the pointer"></canvas></div>CSS
.ef-dgl { position: relative; overflow: hidden; min-height: 18rem; border-radius: 1rem; background: linear-gradient(135deg, var(--ef-a, #2f7d5b), var(--ef-b, #c6f26b)); touch-action: pan-y; }
.ef-dgl__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Depth map shader: a small WebGL fragment shader shifts each pixel of an image by its own depth and by the pointer,
// so near parts move more than far ones. With no depth image it derives depth from brightness (bright = near), so it works on any picture.
// A generated picture is used until you give an image URL (it must allow cross-origin use). Without WebGL the container keeps a CSS gradient.
// mount(root, { image (URL or empty), strength (0-0.15), smoothing (0.02-0.4), colorA, colorB })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-dgl");
var canvas = root.querySelector(".ef-dgl__c");
var gl = canvas.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" });
var opts = {}, raf = 0, visible = true, io = null, prog, u = {}, tex, imgAspect = 1, hovering = false;
var tx = 0, ty = 0, cx = 0, cy = 0, t0 = performance.now(), customImg = null;
var VS = "attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}";
var FS = "precision mediump float;varying vec2 vUv;uniform sampler2D uT;uniform vec2 uP;uniform float uS,uA,uIA;" +
"void main(){vec2 uv=vUv;vec2 s=vec2(1.);if(uA>uIA){s.y=uIA/uA;}else{s.x=uA/uIA;}uv=(uv-.5)*s+.5;" +
"float d=dot(texture2D(uT,uv).rgb,vec3(.299,.587,.114));vec2 off=uP*uS*(d-.5);" +
"gl_FragColor=texture2D(uT,clamp(uv+off,0.,1.));}";
function sh(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
function setup() {
var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
if (!v || !f) return false;
prog = gl.createProgram(); gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false;
gl.useProgram(prog);
var b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(prog, "p"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
["uT", "uP", "uS", "uA", "uIA"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
tex = gl.createTexture();
return true;
}
function upload(source, aspect) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
imgAspect = aspect;
}
// a generated picture: a gradient with a few bright and dark shapes, so brightness works as depth
function generated() {
var c = document.createElement("canvas"); c.width = c.height = 512;
var g = c.getContext("2d");
var gr = g.createLinearGradient(0, 0, 512, 512); gr.addColorStop(0, opts.colorA); gr.addColorStop(1, opts.colorB);
g.fillStyle = gr; g.fillRect(0, 0, 512, 512);
[[150, 170, 120, "rgba(255,255,255,.85)"], [360, 130, 70, "rgba(255,255,255,.6)"], [300, 340, 140, "rgba(0,0,0,.45)"], [110, 380, 60, "rgba(255,255,255,.7)"], [420, 420, 45, "rgba(255,255,255,.9)"]].forEach(function (s) {
var rg = g.createRadialGradient(s[0], s[1], 0, s[0], s[1], s[2]);
rg.addColorStop(0, s[3]); rg.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = rg; g.beginPath(); g.arc(s[0], s[1], s[2], 0, 6.2832); g.fill();
});
return c;
}
function safeUrl(x) { try { var a = new URL(x, window.location.href); return /^https?:$/.test(a.protocol) ? a.href : ""; } catch (e) { return ""; } }
function loadPicture() {
customImg = null;
var url = opts.image ? safeUrl(opts.image) : "";
if (url) {
var im = new Image(); im.crossOrigin = "anonymous";
im.onload = function () { try { customImg = im; upload(im, im.naturalWidth / im.naturalHeight); } catch (e) { customImg = null; upload(generated(), 1); } draw(); };
im.onerror = function () { upload(generated(), 1); draw(); };
im.src = url;
}
upload(generated(), 1);
}
function size() {
var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
var w = Math.max(1, Math.round(wrap.clientWidth * dpr)), h = Math.max(1, Math.round(wrap.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
}
function draw() {
size();
gl.uniform1i(u.uT, 0);
gl.uniform2f(u.uP, cx, cy);
gl.uniform1f(u.uS, opts.strength);
gl.uniform1f(u.uA, canvas.width / canvas.height);
gl.uniform1f(u.uIA, imgAspect);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function frame(now) {
raf = requestAnimationFrame(frame);
if (!visible || document.hidden) return;
if (!hovering) { var s = (now - t0) / 1000; tx = Math.sin(s * 0.7) * 0.5; ty = Math.cos(s * 0.5) * 0.4; }
cx += (tx - cx) * opts.smoothing; cy += (ty - cy) * opts.smoothing;
draw();
}
function move(e) {
var r = wrap.getBoundingClientRect();
tx = ((e.clientX - r.left) / r.width - 0.5) * 2;
ty = -((e.clientY - r.top) / r.height - 0.5) * 2;
hovering = true;
}
function leave() { hovering = false; }
function update(next) {
var prevImage = opts.image, prevA = opts.colorA, prevB = opts.colorB;
opts = Object.assign({ image: "", strength: 0.05, smoothing: 0.1, colorA: "#2f7d5b", colorB: "#c6f26b" }, opts, next || {});
wrap.style.setProperty("--ef-a", opts.colorA);
wrap.style.setProperty("--ef-b", opts.colorB);
if (!ok) return;
if (opts.image !== prevImage || opts.colorA !== prevA || opts.colorB !== prevB) loadPicture();
if (reduce) draw();
}
var ok = gl && setup();
if (!ok) { canvas.style.display = "none"; update(o); return { update: update, destroy: function () {} }; }
update(o);
if (!reduce) {
wrap.addEventListener("pointermove", move);
wrap.addEventListener("pointerleave", leave);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
raf = requestAnimationFrame(frame);
}
return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); wrap.removeEventListener("pointermove", move); wrap.removeEventListener("pointerleave", leave); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"strength":0.05,"smoothing":0.1,"image":"","colorA":"#2f7d5b","colorB":"#c6f26b"});How it works
The picture is uploaded as a texture and drawn by a fragment shader. For each pixel the shader reads the picture's brightness, treats it as depth (bright is near), and shifts the lookup position by the pointer direction times that depth. Near pixels move further than far ones, which fakes parallax from a flat image. With no pointer, a slow drift runs so touch screens still show the effect. For best results supply a picture whose brightness roughly follows depth.
Accessibility and performance
The canvas is a labelled image; supply a more specific label for a real picture. With reduced motion the shader draws one still frame and does not drift or follow the pointer. If WebGL is missing, the container keeps a CSS gradient.
One full-screen triangle and two texture reads per pixel, at a capped resolution, paused when off screen or the tab is hidden. Large textures cost memory, so keep images under about 2000 pixels.
| Section | Hero |
|---|---|
| Techniques | WebGL, Pointer interaction, Parallax and depth |
| Stack | Vanilla JS, WebGL |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Do I need a separate depth map?
No. This version derives depth from brightness, which works on any image. A real depth map as a second texture gives better results and is a small change to the shader.
Why does my image not load?
A remote image must allow cross-origin use. Host it on your own site or a CDN that sends the right header, otherwise the generated picture stays.
Will it work on phones?
Yes. Without a pointer it drifts on its own. Tilt control needs a permission prompt on iOS and is left out.