← Loaders

Dot Shuttle

One bright dot slides along a row of faded dots and back again. Each dot it meets hops over it to swap places on the way out, and ducks under it on the way back. 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-dot-shuttle> where the loader should appear.

Options

Add any of these attributes to the same element.

data-dotsNumber of dots, 3 to 7. Default 4.
data-speedTime for each swap. Default 380 ms.
data-pauseRest at each end of the row. Default 160 ms.
data-colorColour of the sliding dot. Default color, with the others faded.
data-hop="over"The other dots hop over it both ways, instead of ducking under on the way back.

It only moves while it’s on screen. With reduced motion the dots sit still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dot Shuttle: one bright dot slides back and forth along a row of dots.
    // Each dot it meets hops over it (or ducks under on the way back) to swap
    // places. Sized with font-size, coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-dot-shuttle aria-label="Loading"></div>
    // Optional: data-dots (3 to 7, default 4), data-speed (ms for each swap,
    // default 380), data-pause (ms rest at each end, default 160),
    // data-color (colour of the shuttling dot, default color), data-hop="over"
    // (others always hop over it, instead of over then under).
    var DEFAULTS = { dots: 4, speed: 380, pause: 160 };
    var DOT = 0.42, GAP = 0.3, HOP = 0.48; // em

    var css =
      '.dsh{position:relative;display:inline-block}' +
      '.dsh-dot{position:absolute;left:0;width:' + DOT + 'em;height:' + DOT + 'em;border-radius:50%;background:currentColor}' +
      '.dsh-rest{opacity:.32}' +
      '.dsh-lead{z-index:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function build(el) {
      var d = el.dataset;
      var n = Math.max(3, Math.min(7, +d.dots || DEFAULTS.dots));
      var slot = DOT + GAP;
      var overOnly = d.hop === 'over';
      el.classList.add('dsh');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      el.style.width = (n - 1) * slot + DOT + 'em';
      // Room above for hopping over, and below for ducking under.
      var top = HOP + 0.06;
      el.style.height = top + DOT + (overOnly ? 0.06 : HOP + 0.06) + 'em';

      var dots = [];
      for (var i = 0; i < n; i++) {
        var dot = document.createElement('span');
        dot.className = 'dsh-dot ' + (i ? 'dsh-rest' : 'dsh-lead');
        dot.style.top = top + 'em';
        if (!i && d.color) dot.style.background = d.color;
        dot.setAttribute('aria-hidden', 'true');
        el.appendChild(dot);
        anime.utils.set(dot, { x: i * slot + 'em', y: '0em' });
        dots.push(dot);
      }
      if (reduced()) return null;

      var speed = +d.speed || DEFAULTS.speed;
      var pause = d.pause != null ? +d.pause : DEFAULTS.pause;
      var lead = dots[0];
      var tl = anime.createTimeline({ loop: true, autoplay: false });
      var t = 0;
      function swap(other, from, to, up) {
        // The bright dot slides one slot; the other arcs past it the other way.
        tl.add(lead, { x: { from: from * slot + 'em', to: to * slot + 'em' }, duration: speed, ease: 'inOut(3)' }, t);
        tl.add(other, {
          x: { from: to * slot + 'em', to: from * slot + 'em' },
          duration: speed,
          ease: 'inOut(3)'
        }, t);
        tl.add(other, {
          y: [
            { to: (up ? -HOP : HOP) + 'em', duration: speed / 2, ease: 'out(2)' },
            { to: '0em', duration: speed / 2, ease: 'in(2)' }
          ]
        }, t);
        t += speed;
      }
      // Out to the far end, swapping with each dot in turn...
      for (var k = 1; k < n; k++) swap(dots[k], k - 1, k, true);
      t += pause;
      // ...and back again, which puts every dot where it started.
      for (k = n - 1; k >= 1; k--) swap(dots[k], k, k - 1, overOnly);
      t += pause;
      tl.add(lead, { x: '0em', duration: pause || 1 }, t - (pause || 1));
      return [tl];
    }

    function init() {
      document.querySelectorAll('[data-dot-shuttle]').forEach(function (el) {
        if (el._dsh) return;
        el._dsh = true;
        var anims = build(el);
        if (!anims) return;
        new IntersectionObserver(function (entries) {
          anims.forEach(function (a) { entries[0].isIntersecting ? a.play() : a.pause(); });
        }).observe(el);
      });
    }

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