← Backgrounds

Hexagon Grid Glow

A honeycomb of faint hexagons sits behind a section, and every few seconds a wave of light rolls out across it, each cell glowing as the wave passes and fading slowly after. Cells near the pointer glow too; click or tap to send out a wave of your own.

Tap anywhere

How to use it

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

Options

Add any of these attributes to the same element.

data-colorGlow colour. Default cyan #22d3ee.
data-sizeCell radius in px. Default 24.
data-intervalTime between waves. Default 2400 ms.
data-interactive="false"Ignore the pointer: no hover glow, no waves on click.

It’s drawn on a transparent canvas behind your content and paused when off screen. On phones, dragging a finger lights the cells under it. With reduced motion the grid shows without waves.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hexagon Grid Glow: a honeycomb of faint hexagons behind a section, with
    // waves of light rolling out across it every few seconds, each cell
    // glowing as a wave passes and fading slowly after. Cells near the pointer
    // glow too, and a click or tap sends out a wave of your own. Drawn on a
    // transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-hexagon-grid-glow style="background:#070b18">…</section>
    // Optional: data-color (default cyan #22d3ee), data-size (cell radius in
    // px, default 24), data-interval (ms between waves, default 2400),
    // data-interactive="false" (ignore the pointer).
    var DEFAULTS = { color: '#22d3ee', size: 24, interval: 2400 };

    var css =
      '[data-hexagon-grid-glow]{position:relative;isolation:isolate;overflow:hidden}' +
      '.hxg-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);

    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }

    function build(el) {
      var d = el.dataset;
      var c = rgb(d.color || DEFAULTS.color);
      var col = c[0] + ',' + c[1] + ',' + c[2];
      var size = +d.size || DEFAULTS.size;
      var interval = +d.interval || DEFAULTS.interval;
      var interactive = d.interactive !== 'false' && !el.closest('[aria-hidden="true"]');
      var canvas = document.createElement('canvas');
      canvas.className = 'hxg-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, now = 0, next = 300, r = size;
      var cells = [], waves = [], pointer = null, hex = null;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Slightly smaller cells in small boxes, so there are enough of them.
        r = size * Math.min(1, Math.max(0.65, Math.min(w, h) / 320));
        // Pointy-topped hexagons: rows 1.5 r apart, every other row shifted.
        var cw = Math.sqrt(3) * r, ch = 1.5 * r;
        cells = [];
        for (var row = -1; row * ch < h + r; row++) {
          for (var i = -1; i * cw < w + cw; i++) {
            cells.push({ x: i * cw + (row & 1 ? cw / 2 : 0), y: row * ch, g: 0 });
          }
        }
        // One hexagon path, reused for every cell; a small gap between cells.
        hex = new Path2D();
        var ri = r - Math.max(1.5, r * 0.08);
        for (var k = 0; k < 6; k++) {
          var a = Math.PI / 6 + (k * Math.PI) / 3;
          if (k === 0) hex.moveTo(Math.cos(a) * ri, Math.sin(a) * ri);
          else hex.lineTo(Math.cos(a) * ri, Math.sin(a) * ri);
        }
        hex.closePath();
        if (still) draw();
      }

      function wave(x, y) {
        waves.push({ x: x, y: y, start: now, v: Math.max(w, h) * 0.55, life: Math.hypot(w, h) * 1.1 });
      }

      function update(dt) {
        now += dt;
        next -= dt;
        if (next <= 0) {
          wave(R(0.1, 0.9, 3) * w, R(0.1, 0.9, 3) * h);
          next = interval * R(0.8, 1.2, 2);
        }
        var band = r * 1.6;
        for (var i = 0; i < cells.length; i++) {
          var cell = cells[i], target = 0;
          for (var k = 0; k < waves.length; k++) {
            var wv = waves[k], rad = ((now - wv.start) / 1000) * wv.v;
            var dist = Math.hypot(cell.x - wv.x, cell.y - wv.y);
            var off = (dist - rad) / band;
            // A ring of light that dims as it spreads.
            target += Math.exp(-off * off) * Math.max(0, 1 - rad / wv.life);
          }
          if (pointer) {
            var pd = Math.hypot(cell.x - pointer.x, cell.y - pointer.y) / (r * 2.6);
            target += Math.exp(-pd * pd) * 0.9;
          }
          target = Math.min(1, target);
          // Light up fast, fade out slowly.
          cell.g = target > cell.g ? cell.g + (target - cell.g) * Math.min(1, dt / 60) : cell.g * Math.pow(0.5, dt / 380);
        }
        for (var j = waves.length - 1; j >= 0; j--) {
          if (((now - waves[j].start) / 1000) * waves[j].v > waves[j].life) waves.splice(j, 1);
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.lineWidth = 1;
        for (var i = 0; i < cells.length; i++) {
          var cell = cells[i], gl = cell.g;
          ctx.setTransform(dpr, 0, 0, dpr, cell.x * dpr, cell.y * dpr);
          if (gl > 0.02) {
            ctx.fillStyle = 'rgba(' + col + ',' + (gl * 0.38).toFixed(3) + ')';
            ctx.fill(hex);
          }
          ctx.strokeStyle = 'rgba(' + col + ',' + (0.13 + gl * 0.75).toFixed(3) + ')';
          ctx.stroke(hex);
        }
        ctx.setTransform(1, 0, 0, 1, 0, 0);
      }

      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) { draw(); return; }

      if (interactive) {
        // Works with a mouse (hover) and with touch (drag and tap).
        el.addEventListener('pointermove', function (e) {
          var b = el.getBoundingClientRect();
          pointer = { x: e.clientX - b.left, y: e.clientY - b.top };
        });
        el.addEventListener('pointerleave', function () { pointer = null; });
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
        el.addEventListener('pointerdown', function (e) {
          var b = el.getBoundingClientRect();
          wave(e.clientX - b.left, e.clientY - b.top);
        });
      }

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var t = performance.now();
          var dt = last ? Math.min(50, t - last) : 16;
          last = t;
          update(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-hexagon-grid-glow style="background: #070b18; color: #fff">
  <h1>Tap anywhere</h1>
</section>