← Loaders

Double Bounce

Two see-through circles sit on top of each other. One swells as the other shrinks, then they swap, like a soft heartbeat. 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-double-bounce> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime for one full grow and shrink. Default 2000 ms.
data-colorsTwo comma-separated colours, one per circle.
data-opacityHow solid the circles are, from 0.1 to 1. Default 0.6.

It only moves while it’s on screen. With reduced motion it shows one big and one small circle.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Double Bounce: two see-through circles sit on top of each other; one
    // swells as the other shrinks, then they swap, like a soft heartbeat.
    // Sized with font-size, coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-double-bounce aria-label="Loading"></div>
    // Optional: data-speed (ms for one full grow-and-shrink, default 2000),
    // data-colors (two comma-separated colours, one per circle),
    // data-opacity (how see-through the circles are, default 0.6).
    var DEFAULTS = { speed: 2000, opacity: 0.6 };

    var css =
      '.dbb{position:relative;display:inline-block;width:2em;height:2em}' +
      '.dbb-c{position:absolute;inset:0;border-radius:50%;background:currentColor}';
    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 colors = d.colors ? d.colors.split(',') : [];
      var op = Math.max(0.1, Math.min(1, +d.opacity || DEFAULTS.opacity));
      el.classList.add('dbb');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var circles = [];
      for (var i = 0; i < 2; i++) {
        var c = document.createElement('span');
        c.className = 'dbb-c';
        c.style.opacity = op;
        if (colors[i]) c.style.background = colors[i].trim();
        c.setAttribute('aria-hidden', 'true');
        el.appendChild(c);
        circles.push(c);
      }
      if (reduced()) {
        anime.utils.set(circles[0], { scale: 1 });
        anime.utils.set(circles[1], { scale: 0.55 });
        return null;
      }
      var half = (+d.speed || DEFAULTS.speed) / 2;
      return circles.map(function (c, i) {
        anime.utils.set(c, { scale: 0 });
        var a = anime.animate(c, {
          scale: [
            { to: 1, duration: half, ease: 'inOut(2)' },
            { to: 0, duration: half, ease: 'inOut(2)' }
          ],
          loop: true,
          autoplay: false
        });
        // The second circle runs half a beat behind: big when the first is small.
        if (i) a.seek(half);
        return a;
      });
    }

    function init() {
      document.querySelectorAll('[data-double-bounce]').forEach(function (el) {
        if (el._dbb) return;
        el._dbb = 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-double-bounce style="font-size: 24px; color: #6a0dad"></div>