Copied!
Background

Grid distortion field

A canvas grid of lines that bulges outward around the pointer and relaxes back on springs, with Enrichr puzzle pieces floating on top.

← Library

Element · Vanilla JS + Canvas 2D + CSSUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas 2D + CSS

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 "Grid distortion field" for my site using the Enrichr Library element at https://enrichr.dev/elements/grid-distortion-field.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/grid-distortion-field.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:
- accent: #e6ff55
- background: #0c1e18
- density: 22
- radius: 170
- strength: 0.7
- springiness: 0.5

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/grid-distortion-field.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-gdf">
  <div class="ef-gdf__wrap">
    <canvas class="ef-gdf__cv" aria-hidden="true"></canvas>
    <svg class="ef-gdf__piece ef-gdf__piece--a" aria-hidden="true" focusable="false"><symbol id="ef-gdf-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol><use href="#ef-gdf-p4" width="100%" height="100%"/></svg>
    <svg class="ef-gdf__piece ef-gdf__piece--b" aria-hidden="true" focusable="false"><symbol id="ef-gdf-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol><use href="#ef-gdf-p8" width="100%" height="100%"/></svg>
    <svg class="ef-gdf__piece ef-gdf__piece--c" aria-hidden="true" focusable="false"><symbol id="ef-gdf-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol><use href="#ef-gdf-p1" width="100%" height="100%"/></svg>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-gdf { width: 100%; }
.ef-gdf__wrap { position: relative; box-sizing: border-box; width: 100%; min-height: 18rem; height: 100%; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-gdf__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-gdf__piece { position: absolute; pointer-events: none; color: var(--ef-acc, #e6ff55); filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.45)); animation: ef-gdf-float 7s ease-in-out infinite; }
.ef-gdf__piece--a { width: 7.5rem; height: 4.8rem; top: 7%; right: 6%; transform: rotate(8deg); }
.ef-gdf__piece--b { width: 3.6rem; height: 5.6rem; bottom: 8%; left: 6%; color: #c6f26b; opacity: 0.92; animation-delay: -2.4s; }
.ef-gdf__piece--c { width: 3rem; height: 3rem; bottom: 14%; right: 22%; color: #fbf8f1; opacity: 0.85; animation-delay: -4.6s; }
@keyframes ef-gdf-float { 0%, 100% { translate: 0 0; rotate: 0deg; } 50% { translate: 0 -10px; rotate: 5deg; } }
@media (prefers-reduced-motion: reduce) { .ef-gdf__piece { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Grid distortion field: a canvas 2D mesh of lines whose nodes are pushed outward around the pointer (a bulge) and settle back on springs.
// Each node has its own spring (position, velocity), so the grid overshoots and wobbles before relaxing. With no pointer it follows a slow
// wandering point. Pauses off-screen; reduced motion draws one settled still frame.
// mount(root, { background, accent, density (cells across), radius (px), strength (0-1), springiness (0.1-1) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-gdf__wrap");
  var cv = root.querySelector(".ef-gdf__cv");
  var ctx = cv.getContext("2d");
  var opts = {}, offs = [];
  var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, cell = 0;
  var X0, Y0, DX, DY, VX, VY;
  var px = 0, py = 0, hasPtr = false, lastMove = 0;
  var raf = 0, vis = true, last = 0, t = 0, dead = false;

  function build() {
    var r = wrap.getBoundingClientRect();
    w = Math.max(40, r.width); h = Math.max(40, r.height);
    dpr = Math.min(2, window.devicePixelRatio || 1);
    cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
    cell = w / opts.density;
    cols = Math.ceil(w / cell) + 1; rows = Math.ceil(h / cell) + 1;
    var n = (cols + 1) * (rows + 1);
    X0 = new Float32Array(n); Y0 = new Float32Array(n);
    DX = new Float32Array(n); DY = new Float32Array(n);
    VX = new Float32Array(n); VY = new Float32Array(n);
    for (var j = 0; j <= rows; j++) for (var i = 0; i <= cols; i++) { X0[j * (cols + 1) + i] = i * cell; Y0[j * (cols + 1) + i] = j * cell; }
    if (!hasPtr) { px = w * 0.5; py = h * 0.5; }
  }
  function step(dt) {
    var R = opts.radius, S = opts.strength * R * 0.42;
    var k = 40 + opts.springiness * 360, c = 2 * Math.sqrt(k) * (0.9 - opts.springiness * 0.7);
    var n = X0.length;
    for (var q = 0; q < n; q++) {
      var ddx = X0[q] - px, ddy = Y0[q] - py, d = Math.sqrt(ddx * ddx + ddy * ddy), tx = 0, ty = 0;
      if (d < R && d > 0.01) {
        var f = 1 - d / R; f = f * f * (3 - 2 * f);
        var core = Math.min(1, d / (R * 0.45)); core = core * core * (3 - 2 * core); tx = ddx / d * f * S * core; ty = ddy / d * f * S * core;
      }
      VX[q] += ((tx - DX[q]) * k - VX[q] * c) * dt; VY[q] += ((ty - DY[q]) * k - VY[q] * c) * dt;
      DX[q] += VX[q] * dt; DY[q] += VY[q] * dt;
    }
  }
  function draw() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = opts.background; ctx.fillRect(0, 0, w, h);
    var W = cols + 1, i, j, q, m;
    ctx.lineWidth = 1; ctx.lineJoin = "round";
    ctx.strokeStyle = "#2f7d5b"; ctx.globalAlpha = 0.55; ctx.beginPath();
    for (j = 0; j <= rows; j++) { q = j * W; ctx.moveTo(X0[q] + DX[q], Y0[q] + DY[q]); for (i = 1; i <= cols; i++) { q = j * W + i; ctx.lineTo(X0[q] + DX[q], Y0[q] + DY[q]); } }
    for (i = 0; i <= cols; i++) { q = i; ctx.moveTo(X0[q] + DX[q], Y0[q] + DY[q]); for (j = 1; j <= rows; j++) { q = j * W + i; ctx.lineTo(X0[q] + DX[q], Y0[q] + DY[q]); } }
    ctx.stroke();
    ctx.strokeStyle = opts.accent; ctx.lineWidth = 1.5;
    var span = opts.radius * opts.strength * 0.42 || 1;
    for (j = 0; j <= rows; j++) for (i = 0; i <= cols; i++) {
      q = j * W + i;
      if (i < cols) { m = Math.sqrt((DX[q] * DX[q] + DY[q] * DY[q] + DX[q + 1] * DX[q + 1] + DY[q + 1] * DY[q + 1]) * 0.5) / span; if (m > 0.04) { ctx.globalAlpha = Math.min(1, m * 1.4); ctx.beginPath(); ctx.moveTo(X0[q] + DX[q], Y0[q] + DY[q]); ctx.lineTo(X0[q + 1] + DX[q + 1], Y0[q + 1] + DY[q + 1]); ctx.stroke(); } }
      if (j < rows) { m = Math.sqrt((DX[q] * DX[q] + DY[q] * DY[q] + DX[q + W] * DX[q + W] + DY[q + W] * DY[q + W]) * 0.5) / span; if (m > 0.04) { ctx.globalAlpha = Math.min(1, m * 1.4); ctx.beginPath(); ctx.moveTo(X0[q] + DX[q], Y0[q] + DY[q]); ctx.lineTo(X0[q + W] + DX[q + W], Y0[q + W] + DY[q + W]); ctx.stroke(); } }
    }
    ctx.fillStyle = "#c6f26b";
    for (q = 0; q < X0.length; q++) {
      m = Math.sqrt(DX[q] * DX[q] + DY[q] * DY[q]) / span;
      if (m > 0.12) { ctx.globalAlpha = Math.min(1, m); ctx.beginPath(); ctx.arc(X0[q] + DX[q], Y0[q] + DY[q], 1.6, 0, 6.2832); ctx.fill(); }
    }
    ctx.globalAlpha = 1;
  }
  function still() {
    px = w * 0.42; py = h * 0.5;
    for (var i = 0; i < 160; i++) step(1 / 60);
    draw();
  }
  function frame(now) {
    raf = 0;
    if (dead || !vis) return;
    var dt = Math.min(1 / 30, (now - last) / 1000 || 1 / 60); last = now; t += dt;
    if (!hasPtr || now - lastMove > 2200) {
      px = w * (0.5 + 0.36 * Math.sin(t * 0.7)); py = h * (0.5 + 0.3 * Math.sin(t * 1.1 + 1));
    }
    step(dt); draw();
    raf = requestAnimationFrame(frame);
  }
  function kick() { if (!reduce && !raf && vis && !dead) { last = performance.now(); raf = requestAnimationFrame(frame); } }
  function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }
  on(wrap, "pointermove", function (e) {
    var r = wrap.getBoundingClientRect();
    px = e.clientX - r.left; py = e.clientY - r.top; hasPtr = true; lastMove = performance.now();
  });
  on(wrap, "pointerleave", function () { hasPtr = false; });
  var ro = new ResizeObserver(function () { if (dead) return; build(); if (reduce) still(); });
  ro.observe(wrap);
  offs.push(function () { ro.disconnect(); });
  var io = new IntersectionObserver(function (es) { vis = es[0].isIntersecting; if (vis) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });
  io.observe(wrap);
  offs.push(function () { io.disconnect(); });

  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", density: 22, radius: 170, strength: 0.7, springiness: 0.5 }, opts, next || {});
    opts.density = Math.max(8, Math.min(48, Math.round(opts.density)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.density !== opts.density || !X0) build();
    if (reduce) still(); else kick();
  }
  update(o);
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    offs.forEach(function (f) { f(); }); offs = [];
  }
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","background":"#0c1e18","density":22,"radius":170,"strength":0.7,"springiness":0.5});

How it works

The grid is a mesh of nodes, each with a rest position, an offset and a velocity. Every frame each node looks at its distance to the pointer; inside the reach radius it gets a target offset pointing away from the pointer, scaled by a smoothstep falloff, which makes the bulge. A damped spring pulls the offset towards that target, so nodes overshoot and wobble before settling. Lines are stroked in a calm green first, then segments that have moved are re-stroked in the accent with opacity proportional to how far they moved. With no pointer, a slow wandering point drives the mesh. This is an original build from the general idea of a distorted grid, not a copy of any library.

Accessibility and performance

Decorative: the canvas and puzzle pieces are aria-hidden and take no focus. Reduced motion draws one settled frame with a fixed bulge and stops the animation loop and the floating pieces. Motion is gentle and never flashes. Keep any text over it high contrast against the dark ground.

One canvas, one rAF loop, paused when the box leaves the viewport. Cost grows with the number of nodes (cells across squared), so keep density under about 40 on busy pages. Device pixel ratio is capped at 2.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas 2D, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byGrid Distortion (inspiration only, original rebuild) by React Bits on reactbits.dev. Original code, written from scratch.

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.

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

FAQ

How do I make it springier or stiffer?

Use the springiness control: higher is a stiffer, snappier spring with more ringing; lower is looser and slower to settle.

Does it work on touch?

Yes. Pointer events cover touch drag; when you lift off, the grid returns to its slow idle wander.

Can it sit behind text?

Yes. Position it absolutely behind your content; the pointer listener is on the box itself, so give the box pointer events or forward them.