← Backgrounds

Grid Warp

A grid behind the section sags under the pointer like a trampoline: the lines bend in towards it and light up, then spring back with a wobble when the pointer moves on. Press or tap to push it deeper. With autoplay, a pretend pointer wanders and bounces on it until a real one arrives.

Bend the rules

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-grid-warp to a section with a dark background.

Options

Add any of these attributes to the same element.

data-autoplayA pretend pointer wanders and bounces on the grid until the real pointer arrives, and again when it leaves. Use it so phones see it move.
data-colorLine colour. Default rgba(255,255,255,.22).
data-glowColour of the lines where it sags. Default violet #a855f7.
data-sizeGrid square in px. Default 36.
data-depthHow deep it sags, as a multiplier. Default 1.

It’s drawn on a transparent canvas behind your content and paused when off screen; clicks still reach your links and buttons. On touch screens it follows your finger while you touch. With reduced motion it shows a still dip in the middle.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Grid Warp: a grid of lines behind the section sags under the pointer
    // like a trampoline, the lines bending in towards it and lighting up, and
    // springs back with a wobble when the pointer moves on. Press or tap to
    // push it deeper. Drawn on a transparent canvas behind your content, and
    // paused when off screen.
    // Usage: <section data-grid-warp style="background:#0b0a1a">…</section>
    // Optional: data-color (line colour, default rgba(255,255,255,.22)),
    // data-glow (colour where it sags, default #a855f7), data-size (grid cell
    // in px, default 36), data-depth (how deep it sags, default 1),
    // data-autoplay (a pretend pointer wanders and bounces on it until the
    // real pointer arrives; also how it behaves on touch screens).
    var DEFAULTS = { color: 'rgba(255,255,255,.22)', glow: '#a855f7', size: 36, depth: 1 };

    var css =
      '[data-grid-warp]{position:relative;isolation:isolate;overflow:hidden}' +
      '.gwp-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color, glow = d.glow || DEFAULTS.glow;
      var cell = +d.size || DEFAULTS.size;
      var depthK = d.depth !== undefined ? +d.depth : DEFAULTS.depth;
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'gwp-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, gap = 0, sub = 2;
      var ox = null, oy = null, vx = null, vy = null;
      // The pointer (or pretend pointer), how hard it presses, and its targets.
      var px = 0, py = 0, press = 0, pressGoal = 0, real = false, lastReal = -1e9, down = false, t = 0;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Points every half cell, so bent lines stay smooth.
        gap = cell / sub;
        cols = Math.ceil(w / gap) + 3;
        rows = Math.ceil(h / gap) + 3;
        ox = new Float32Array(cols * rows); oy = new Float32Array(cols * rows);
        vx = new Float32Array(cols * rows); vy = new Float32Array(cols * rows);
        if (!px) { px = w / 2; py = h / 2; }
        draw();
      }

      // Each point springs towards where the sag wants it, so the grid
      // overshoots and wobbles like a real trampoline.
      function physics(dt) {
        // Small steps keep the springs steady even when frames drop.
        if (dt > 1 / 50) { physics(dt / 2); physics(dt / 2); return; }
        var R = Math.max(60, Math.min(w, h) * 0.32) * (0.75 + press * 0.35);
        var pull = Math.min(0.62, press * 0.42 * depthK);
        var k = 120 * dt, damp = Math.pow(0.0009, dt);
        for (var j = 0; j < rows; j++) {
          for (var i = 0; i < cols; i++) {
            var n = j * cols + i;
            var bx = (i - 1) * gap - (cols * gap - w) / 2 + gap, by = (j - 1) * gap - (rows * gap - h) / 2 + gap;
            var dx = px - bx, dy = py - by;
            var s = Math.exp(-(dx * dx + dy * dy) / (2 * R * R));
            // Pulled in towards the dip, and a touch further down, as if seen from above.
            var tx = dx * s * pull, ty = dy * s * pull + s * R * 0.12 * pull;
            vx[n] = (vx[n] + (tx - ox[n]) * k) * damp;
            vy[n] = (vy[n] + (ty - oy[n]) * k) * damp;
            ox[n] += vx[n] * dt * 60 * 0.12;
            oy[n] += vy[n] * dt * 60 * 0.12;
          }
        }
      }

      function line(get, count) {
        // A smooth curve through the points, via the midpoints between them.
        var a = get(0), b;
        ctx.moveTo(a[0], a[1]);
        for (var i = 1; i < count - 1; i++) {
          a = get(i); b = get(i + 1);
          ctx.quadraticCurveTo(a[0], a[1], (a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
        }
        b = get(count - 1);
        ctx.lineTo(b[0], b[1]);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var x0 = -(cols * gap - w) / 2 + gap, y0 = -(rows * gap - h) / 2 + gap;
        function pt(i, j) { var n = j * cols + i; return [(i - 1) * gap + x0 + ox[n], (j - 1) * gap + y0 + oy[n]]; }
        ctx.beginPath();
        for (var j = 0; j < rows; j += sub) line(function (i) { return pt(i, j); }, cols);
        for (var i = 0; i < cols; i += sub) line(function (j) { return pt(i, j); }, rows);
        ctx.lineWidth = 1;
        ctx.strokeStyle = color;
        ctx.stroke();
        // The same lines again, lit only around the dip.
        var R = Math.max(60, Math.min(w, h) * 0.32);
        var g = ctx.createRadialGradient(px, py, 0, px, py, R * (1 + press * 0.4));
        g.addColorStop(0, glow);
        g.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.globalAlpha = Math.min(1, press * 1.1);
        ctx.lineWidth = 1.5;
        ctx.strokeStyle = g;
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        // A still frame with a gentle dip in the middle.
        press = 0.9;
        for (var s = 0; s < 240; s++) physics(1 / 60);
        draw();
        return;
      }

      function pos(e) { var b = el.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; }
      var goal = [w / 2, h / 2];
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType !== 'mouse' && !down) return;
        goal = pos(e); real = true; lastReal = performance.now();
      });
      el.addEventListener('pointerdown', function (e) { down = true; goal = pos(e); real = true; lastReal = performance.now(); pressGoal = 1; });
      window.addEventListener('pointerup', function () { if (down) { down = false; pressGoal = 0; lastReal = performance.now(); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { real = false; lastReal = performance.now(); } });

      var last = 0, nextBounce = 2;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = (last ? Math.min(50, now - last) : 16) / 1000;
          last = now;
          t += dt;
          var idle = !down && now - lastReal > (real ? 4000 : 1500);
          if (idle) real = false;
          if (auto && !real && !down) {
            // A pretend pointer drifts in loops and now and then bounces.
            goal = [w * (0.5 + 0.32 * Math.sin(t * 0.47)), h * (0.5 + 0.3 * Math.sin(t * 0.71 + 1))];
            nextBounce -= dt;
            if (nextBounce <= 0) { pressGoal = 1; nextBounce = 2.6; }
            else if (nextBounce < 2.2) pressGoal = 0;
          } else if (!auto && !real && !down) {
            pressGoal = 0;
          }
          var on = auto || real || down ? 1 : 0;
          var f = 1 - Math.pow(0.002, dt);
          px += (goal[0] - px) * f;
          py += (goal[1] - py) * f;
          press += ((on ? 0.4 + pressGoal : 0) - press) * (1 - Math.pow(0.02, dt));
          physics(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-grid-warp]').forEach(function (el) {
        if (el._gwp) return;
        el._gwp = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-grid-warp data-autoplay style="background: #0b0a1a; color: #fff">
  <h1>Bend the rules</h1>
</section>