← Hover & cursor

Cursor Grid Trail

The section is laid out as a faint grid, and the cells the pointer passes over light up, then cool and fade away behind it, leaving a glowing trail of squares.

Leave a trail

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-cursor-grid-trail to the section. The grid is drawn behind its content.

Options

Add any of these attributes to the same element.

data-cellSize of each cell, in px. Default 32.
data-colorColour of a freshly lit cell. Default #a855f7.
data-fade-toColour cells cool to as they fade. Default #38bdf8.
data-lifeHow long a cell stays lit. Default 900 ms.
data-linesColour of the grid lines. Default a faint white, for dark sections; none to hide them.
data-autoplayA pretend pointer wanders over the grid while a real one isn’t in the section. Handy for demos and touch screens.

It looks best on a dark section; on a light one, set data-lines to something like rgba(0,0,0,.06). Fast moves still light every cell along the way. On touch screens, drag a finger across it. With reduced motion turned on, only the grid shows.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cursor Grid Trail: the area is laid out as a faint grid, and the cells
    // the pointer passes over light up, then fade away behind it, leaving a
    // glowing trail of squares.
    // Usage: <section data-cursor-grid-trail>…</section>
    // Optional: data-cell (cell size, px, default 32), data-color (lit cell
    // colour, default #a855f7), data-fade-to (colour cells cool to as they
    // fade, default #38bdf8), data-life (how long a cell stays lit, ms,
    // default 900), data-lines (colour of the grid lines, default a faint
    // white; "none" to hide them), data-autoplay (a pretend pointer wanders
    // across the grid while a real one isn't in the area; handy for demos and
    // touch screens).
    // The grid is drawn behind the area's content and doesn't catch clicks.
    var DEFAULTS = { cell: 32, color: '#a855f7', fadeTo: '#38bdf8', life: 900, lines: 'rgba(255,255,255,.07)' };

    var css =
      '.cgr{position:relative;overflow:hidden;isolation:isolate}' +
      '.cgr-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 reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var s = probe.fillStyle;
      if (s.charAt(0) === '#') return [parseInt(s.substr(1, 2), 16), parseInt(s.substr(3, 2), 16), parseInt(s.substr(5, 2), 16)];
      var m = s.match(/[\d.]+/g) || [0, 0, 0];
      return [+m[0], +m[1], +m[2]];
    }

    function setup(el) {
      var d = el.dataset;
      var cell = Math.max(8, num(d.cell, DEFAULTS.cell));
      var hot = rgb(d.color || DEFAULTS.color), cool = rgb(d.fadeTo || DEFAULTS.fadeTo);
      var life = Math.max(100, num(d.life, DEFAULTS.life));
      var lines = d.lines || DEFAULTS.lines;
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      el.classList.add('cgr');

      var canvas = document.createElement('canvas');
      canvas.className = 'cgr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');

      var w = 0, h = 0, cols = 0, rows = 0, ox = 0, oy = 0, lit = new Float32Array(0);
      function size() {
        w = el.clientWidth; h = el.clientHeight;
        var dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        // Centre the grid, so the part-cells at the edges match.
        cols = Math.ceil(w / cell) + 1; rows = Math.ceil(h / cell) + 1;
        ox = (w - (cols - 1) * cell) / 2 - cell / 2; oy = (h - (rows - 1) * cell) / 2 - cell / 2;
        lit = new Float32Array(cols * rows);
        draw();
      }

      function light(x, y) {
        var c = Math.floor((x - ox) / cell), r = Math.floor((y - oy) / cell);
        if (c < 0 || r < 0 || c >= cols || r >= rows) return;
        lit[c + r * cols] = 1;
      }
      // Light every cell along the way, so fast moves leave an unbroken trail.
      var last = null;
      function trace(x, y) {
        if (last) {
          var dx = x - last.x, dy = y - last.y;
          var n = Math.max(1, Math.ceil(Math.sqrt(dx * dx + dy * dy) / (cell / 3)));
          for (var i = 1; i <= n; i++) light(last.x + dx * i / n, last.y + dy * i / n);
        } else light(x, y);
        last = { x: x, y: y };
      }

      function draw() {
        ctx.clearRect(0, 0, w, h);
        if (lines !== 'none') {
          ctx.strokeStyle = lines;
          ctx.lineWidth = 1;
          ctx.beginPath();
          for (var c = 0; c <= cols; c++) { var x = Math.round(ox + c * cell) + 0.5; ctx.moveTo(x, 0); ctx.lineTo(x, h); }
          for (var r = 0; r <= rows; r++) { var y = Math.round(oy + r * cell) + 0.5; ctx.moveTo(0, y); ctx.lineTo(w, y); }
          ctx.stroke();
        }
        var any = false, gap = Math.max(1, cell * 0.06), rad = Math.min(6, cell * 0.16);
        for (var i = 0; i < lit.length; i++) {
          var v = lit[i];
          if (v <= 0.01) continue;
          any = true;
          var cx = ox + (i % cols) * cell, cy = oy + Math.floor(i / cols) * cell;
          // Fresh cells are bright and hot; older ones cool and fade.
          var k = 1 - v;
          var col = 'rgba(' + Math.round(hot[0] + (cool[0] - hot[0]) * k) + ',' + Math.round(hot[1] + (cool[1] - hot[1]) * k) + ',' +
            Math.round(hot[2] + (cool[2] - hot[2]) * k) + ',' + (Math.pow(v, 1.4) * 0.9).toFixed(3) + ')';
          ctx.fillStyle = col;
          ctx.beginPath();
          if (ctx.roundRect) ctx.roundRect(cx + gap, cy + gap, cell - gap * 2, cell - gap * 2, rad);
          else ctx.rect(cx + gap, cy + gap, cell - gap * 2, cell - gap * 2);
          ctx.fill();
        }
        return any;
      }

      var hover = false, hold = 0, visible = false, raf = 0, prev = 0, t = Math.random() * 10;
      function frame(now) {
        raf = 0;
        if (!visible) return;
        var dt = prev ? Math.min(64, now - prev) : 16;
        prev = now;
        var fake = auto && !hover && Date.now() > hold;
        if (fake) {
          // A wandering path that swoops across the grid.
          t += dt * 0.0011;
          trace(w * (0.5 + 0.42 * Math.sin(t * 1.3) * Math.cos(t * 0.37)), h * (0.5 + 0.4 * Math.sin(t * 2.1 + 1)));
        }
        var step = dt / life;
        for (var i = 0; i < lit.length; i++) if (lit[i] > 0) lit[i] = Math.max(0, lit[i] - step);
        var any = draw();
        if (fake || any) raf = requestAnimationFrame(frame);
        else prev = 0;
      }
      function wake() { if (!raf && visible && !still) raf = requestAnimationFrame(frame); }

      function local(e) {
        var b = el.getBoundingClientRect();
        return { x: e.clientX - b.left, y: e.clientY - b.top };
      }
      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true; last = null;
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 2500;
        else hover = true;
        if (still) return;
        var p = local(e);
        trace(p.x, p.y);
        wake();
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType !== 'touch') { hover = false; last = null; }
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 2500; last = null;
        if (still) return;
        var p = local(e);
        trace(p.x, p.y);
        wake();
      });

      size();
      new ResizeObserver(function () { size(); wake(); }).observe(el);
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-cursor-grid-trail]').forEach(function (el) {
        if (el._cgr) return;
        el._cgr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="hero" data-cursor-grid-trail>…</section>

<!-- bigger, warmer cells that linger: -->
<section data-cursor-grid-trail data-cell="48" data-color="#f97316" data-fade-to="#e11d48" data-life="1500">…</section>