← Loaders

Pulse Loader

A dot gives a little heartbeat and sends a ring rippling outward, again and again, each ring fading as it spreads. Use it as a loader, or as a “live” or “you are here” marker. Size it with font-size and colour it with color.

Live

How to use it

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

Options

Add any of these attributes to the same element.

data-ringsRings in flight at once, 1 to 5. Default 3.
data-speedTime for one ring to spread. Default 2100 ms.
data-fillSoft filled ripples instead of rings.

It only ripples while it’s on screen. With reduced motion the rings sit still around the dot.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pulse Loader: a dot gives a little heartbeat and sends a ring rippling
    // outward, again and again, each ring thinning and fading as it spreads.
    // Use it as a loader, or as a "live" or "you are here" marker. Sized with
    // font-size, coloured with color. It ripples while it's on screen.
    // Usage: <div data-pulse-loader aria-label="Loading"></div>
    // Optional: data-rings (rings in flight at once, 1 to 5, default 3),
    // data-speed (ms for one ring to spread, default 2100), data-fill (soft
    // filled ripples instead of rings).
    var DEFAULTS = { rings: 3, speed: 2100 };

    var css =
      '.pls{position:relative;display:inline-grid;place-items:center;width:3em;height:3em;vertical-align:middle}' +
      '.pls-ring,.pls-dot{grid-area:1/1;border-radius:50%}' +
      '.pls-ring{width:0.7em;height:0.7em;box-sizing:border-box;border:0.08em solid currentColor;opacity:0}' +
      '.pls[data-fill]:not([data-fill=false]) .pls-ring{border:0;background:currentColor}' +
      '.pls-dot{position:relative;width:0.7em;height:0.7em;background:currentColor}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var n = Math.max(1, Math.min(5, +d.rings || DEFAULTS.rings));
      var fill = d.fill !== undefined && d.fill !== 'false';
      el.classList.add('pls');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var rings = [];
      for (var i = 0; i < n; i++) {
        var ring = document.createElement('span');
        ring.className = 'pls-ring';
        el.appendChild(ring);
        rings.push(ring);
      }
      var dot = document.createElement('span');
      dot.className = 'pls-dot';
      el.appendChild(dot);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        // Still rings, fading outward, so it keeps its look without moving.
        rings.forEach(function (r, i) {
          var t = (i + 1) / (n + 1);
          r.style.width = r.style.height = 0.7 + 2.3 * t + 'em';
          r.style.opacity = (fill ? 0.25 : 0.7) * (1 - t);
        });
        return null;
      }

      var speed = +d.speed || DEFAULTS.speed;
      var beat = speed / n;
      // The dot squeezes in, then pops back as each ring leaves it.
      var anims = [anime.animate(dot, {
        scale: [{ to: 0.78, duration: beat * 0.35, ease: 'inOut(2)' }, { to: 1, duration: beat * 0.65, ease: 'outElastic(1, .5)' }],
        loop: true,
        autoplay: false
      })];
      // Rings set off one beat apart, timed to the pop, so they stay evenly
      // spaced. Growing the size (not scale) keeps their line thin.
      rings.forEach(function (ring, i) {
        anims.push(anime.animate(ring, {
          width: ['0.7em', '3em'],
          height: ['0.7em', '3em'],
          opacity: [fill ? 0.35 : 0.9, 0],
          duration: speed,
          delay: beat * (0.35 + i),
          ease: 'out(2.6)',
          loop: true,
          autoplay: false
        }));
      });
      return anims;
    }

    function init() {
      document.querySelectorAll('[data-pulse-loader]').forEach(function (el) {
        if (el._pls) return;
        el._pls = true;
        var anims = build(el);
        if (!anims) return;
        // Only ripple while visible, to save battery.
        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-pulse-loader style="font-size: 24px; color: #6a0dad"></div>

<!-- a "live" marker: -->
<span class="live"><span data-pulse-loader data-rings="2" aria-hidden="true"></span> Live</span>