Copied!
Hero

Depth map shader

A WebGL shader that shifts an image by its own depth and the pointer, so near parts move more than far ones.

← Library

Element · Vanilla JS + WebGLUpdated November 5, 2026

HeroElementVanilla JS + WebGL

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.

SectionHero
TechniquesWebGL, Pointer interaction, Parallax and depth
StackVanilla JS, WebGL
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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.

Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

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.