← Loaders

Honeycomb Pulse

A cluster of hexagons pulses out from the middle. The centre cell swells and brightens first, then each ring round it a beat later, so a ripple runs out through the honeycomb and fades. Give each ring its own colour for a warmer glow. Size it with font-size and colour it with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-honeycomb-pulse> where the loader should appear.

Options

Add any of these attributes to the same element.

data-ringsRings of cells round the centre, 1 or 2. Default 2.
data-speedTime between pulses. Default 1600 ms.
data-colorsComma-separated colours, one per ring from the centre out.
data-gapSpace between the cells, 0 to 0.3 of a cell. Default 0.12.

It’s 3em wide. It only moves while it’s on screen. With reduced motion the cells sit still, fading towards the edge.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Honeycomb Pulse: a cluster of hexagons pulses out from the middle. The
    // centre cell swells and brightens first, then each ring of cells round it
    // a beat later, so a ripple runs out through the honeycomb.
    // Sized with font-size (about 3em across), coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-honeycomb-pulse aria-label="Loading"></div>
    // Optional: data-rings (1 or 2 rings round the centre, default 2),
    // data-speed (ms between pulses, default 1600), data-colors (comma-
    // separated, one per ring from the centre out), data-gap (space between
    // cells, 0 to 0.3, default 0.12).
    var DEFAULTS = { rings: 2, speed: 1600, gap: 0.12 };
    var WIDTH = 3; // em

    var css =
      '.hcp{position:relative;display:inline-block;vertical-align:middle}' +
      '.hcp-c{position:absolute;left:50%;top:50%;background:currentColor;clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);will-change:transform,opacity}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Distance in rings between two hex cells in axial coordinates.
    function ringOf(q, r) { return Math.max(Math.abs(q), Math.abs(r), Math.abs(q + r)); }

    function build(el) {
      var d = el.dataset;
      var rings = +d.rings === 1 ? 1 : DEFAULTS.rings;
      var gap = Math.max(0, Math.min(0.3, d.gap != null && !isNaN(+d.gap) ? +d.gap : DEFAULTS.gap));
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
      // Pointy-topped cells of "radius" s (centre to corner) fill WIDTH across.
      var s = WIDTH / (Math.sqrt(3) * (2 * rings + 1));
      var w = Math.sqrt(3) * s, h = 2 * s;
      el.classList.add('hcp');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      el.style.width = WIDTH + 'em';
      el.style.height = (s * (3 * rings + 2)).toFixed(3) + 'em';

      var cells = [];
      for (var q = -rings; q <= rings; q++) {
        for (var r = -rings; r <= rings; r++) {
          var k = ringOf(q, r);
          if (k > rings) continue;
          var c = document.createElement('span');
          c.className = 'hcp-c';
          c.setAttribute('aria-hidden', 'true');
          var x = w * (q + r / 2), y = 1.5 * s * r;
          c.style.width = (w * (1 - gap)).toFixed(3) + 'em';
          c.style.height = (h * (1 - gap)).toFixed(3) + 'em';
          c.style.margin = (-h * (1 - gap) / 2).toFixed(3) + 'em 0 0 ' + (-w * (1 - gap) / 2).toFixed(3) + 'em';
          c.style.translate = x.toFixed(3) + 'em ' + y.toFixed(3) + 'em';
          if (colors.length) c.style.background = colors[Math.min(k, colors.length - 1)];
          el.appendChild(c);
          // A touch of jitter round each ring so the ripple feels organic.
          var a = Math.atan2(y, x);
          cells.push({ el: c, k: k, lag: k * 0.16 + (k ? 0.03 * Math.sin(a * 3) : 0) });
        }
      }

      function draw(p) {
        cells.forEach(function (c) {
          var u = ((p - c.lag) % 1 + 1) % 1;
          // A quick swell and a slower settle, then rest until the next pulse.
          var b = u < 0.18 ? Math.sin(u / 0.18 * Math.PI / 2) : u < 0.6 ? Math.pow(Math.cos((u - 0.18) / 0.42 * Math.PI / 2), 2) : 0;
          c.el.style.transform = 'scale(' + (0.72 + 0.28 * b).toFixed(3) + ')';
          c.el.style.opacity = (0.28 + 0.72 * b).toFixed(3);
        });
      }

      if (reduced()) {
        cells.forEach(function (c) { c.el.style.opacity = (1 - c.k * 0.25).toFixed(2); });
        return null;
      }
      draw(0.05);
      var speed = Math.max(500, +d.speed || DEFAULTS.speed);
      var t = 0.05 * speed, last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          draw(t / speed);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-honeycomb-pulse]').forEach(function (el) {
        if (el._hcp) return;
        el._hcp = true;
        var timer = build(el);
        if (!timer) return;
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? timer.play() : timer.pause();
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-honeycomb-pulse style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>