← Loaders

Gooey Dots

Dots that merge and pull apart like drops of liquid. They run together into one blob, reaching for each other with a stretchy neck, then spring apart again. Or let one dot glide along the row and back, swallowing and leaving each dot it passes. 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-gooey-dots> where the loader should appear.

Options

Add any of these attributes to the same element.

data-style="shuttle"One dot glides along the row and back through the others.
data-dotsNumber of dots, 3 to 6. Default 3 (4 for shuttle).
data-speedTime for one cycle. Default 1800 ms.
data-colorsComma-separated colours, one per dot. Where they merge the colours meet edge to edge.

The goo is an SVG blur-and-contrast filter, sized from the loader’s font-size when it starts. It only moves while it’s on screen. With reduced motion the dots sit still in a row.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Gooey Dots: dots that merge and pull apart like drops of liquid. A blur
    // and contrast filter makes them reach for each other with a stretchy neck
    // as they meet and snap apart as they separate. Sized with font-size,
    // coloured with color. It only moves while it's on screen.
    // Usage: <div data-gooey-dots aria-label="Loading"></div>
    // Optional: data-style ("merge", the default: the dots run together into
    // one blob and spring apart; "shuttle": one dot glides along the row and
    // back, swallowing and leaving each dot it passes), data-dots (3 to 6,
    // default 3 for merge, 4 for shuttle), data-speed (ms for one cycle,
    // default 1800), data-colors (comma-separated, one per dot).
    var DEFAULTS = { style: 'merge', speed: 1800 };
    var DOT = 0.6, GAP = 1, PAD = 0.4; // em

    var css =
      '.gdt{display:inline-block;vertical-align:middle}' +
      '.gdt-goo{position:relative;width:100%;height:100%}' +
      '.gdt-d{position:absolute;top:50%;left:50%;width:' + DOT + 'em;height:' + DOT + 'em;margin:' + -DOT / 2 + 'em 0 0 ' + -DOT / 2 + 'em;border-radius:50%;background:currentColor}' +
      '.gdt-svg{position:absolute;width:0;height:0;overflow:hidden}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;

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

    function build(el) {
      var d = el.dataset;
      var shuttle = d.style === 'shuttle';
      var n = Math.max(3, Math.min(6, Math.round(+d.dots) || (shuttle ? 4 : 3)));
      var speed = Math.max(400, +d.speed || DEFAULTS.speed);
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
      var id = 'gdt-goo-' + (++uid);
      el.classList.add('gdt');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      el.style.width = ((shuttle ? n : n - 1) * GAP + DOT + PAD * 2) + 'em';
      el.style.height = (DOT + PAD * 2) + 'em';

      // The goo filter: blur the dots together, then sharpen the edge back up.
      var blur = parseFloat(getComputedStyle(el).fontSize) * 0.15;
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'gdt-svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-80%" width="160%" height="260%" color-interpolation-filters="sRGB">' +
        '<feGaussianBlur in="SourceGraphic" stdDeviation="' + blur.toFixed(2) + '" result="b"/>' +
        '<feColorMatrix in="b" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10"/></filter>';
      el.appendChild(svg);
      var goo = document.createElement('span');
      goo.className = 'gdt-goo';
      goo.style.display = 'block';
      goo.style.filter = 'url(#' + id + ')';
      goo.setAttribute('aria-hidden', 'true');
      el.appendChild(goo);

      // In shuttle mode the dots rest in slots 1..n-1 of n+1 and the traveller
      // goes from slot 0 to slot n and back.
      var slots = shuttle ? n + 1 : n;
      var dots = [], home = [];
      for (var j = 0; j < slots; j++) home.push((j - (slots - 1) / 2) * GAP);
      for (var i = 0; i < n; i++) {
        var dot = document.createElement('span');
        dot.className = 'gdt-d';
        if (colors[i]) dot.style.background = colors[i];
        goo.appendChild(dot);
        anime.utils.set(dot, { x: home[i] + 'em' });
        dots.push(dot);
      }
      if (reduced()) return null;

      var tl = anime.createTimeline({ loop: true, autoplay: false });
      if (shuttle) {
        // The first dot travels from one end to the other and back, through
        // the dots resting in between.
        var mover = dots[0], far = home[n], near = home[0];
        tl.add(mover, { x: far + 'em', duration: speed / 2, ease: 'inOut(2.5)' }, 0)
          .add(mover, { x: near + 'em', duration: speed / 2, ease: 'inOut(2.5)' }, speed / 2);
        // Each resting dot swells a little as the traveller passes through it.
        for (var k = 1; k < n; k++) {
          var a = (home[k] - near) / (far - near);
          // When does an inOut(2.5) glide reach this fraction of the way?
          var f = a < 0.5 ? Math.pow(2 * a, 1 / 2.5) / 2 : 1 - Math.pow(2 - 2 * a, 1 / 2.5) / 2;
          [f * speed / 2, speed - f * speed / 2].forEach(function (t) {
            tl.add(dots[k], { scale: [{ to: 1.3, duration: speed * 0.05, ease: 'out(2)' }, { to: 1, duration: speed * 0.12, ease: 'out(3)' }] }, Math.max(0, t - speed * 0.04));
          });
        }
      } else {
        // Crowd in to the middle, wobble there as one blob, then spring apart.
        var mid = speed * 0.38, hold = speed * 0.14;
        dots.forEach(function (dot, i) {
          var lag = Math.abs(home[i]) / GAP * speed * 0.04;
          tl.add(dot, { x: home[i] * 0.22 + 'em', scale: 1.1, duration: mid, ease: 'inOut(3)' }, lag)
            .add(dot, { x: home[i] + 'em', scale: 1, duration: speed - mid - hold - lag, ease: 'outElastic(1, .55)' }, mid + hold + lag);
        });
      }
      return tl;
    }

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

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

<!-- one dot shuttling through the others: -->
<div data-gooey-dots data-style="shuttle"></div>