← Loaders

Equaliser Bars

A row of bars bounce up and down, each at its own pace, like the level meter on a music player. Use it as a loader or a “now playing” marker. Give it two colours and taller bars reveal more of the gradient. 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-equaliser-bars> where the loader should appear.

Options

Add any of these attributes to the same element.

data-barsNumber of bars, 3 to 12. Default 5.
data-speedRoughly how long each rise or fall takes. Default 360 ms.
data-colorsOne colour, or two for a gradient from bottom to top.
data-mirrorBars grow up and down from the middle, like a sound wave.
data-gapSpace between bars as a share of a bar’s width. Default 0.6.

Every bar gets its own random levels, so no two loaders look the same. It only moves while it’s on screen. With reduced motion the bars sit still in a gentle arch.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Equaliser Bars: a row of bars bounce up and down at their own pace, like
    // the level meter on a music player. Sized with font-size, coloured with
    // color. It only moves while it's on screen.
    // Usage: <div data-equaliser-bars aria-label="Loading"></div>
    // Optional: data-bars (3 to 12, default 5), data-speed (ms for each
    // rise or fall, roughly, default 360), data-colors (two comma-separated
    // colours for a bottom-to-top gradient, or one), data-mirror (bars grow
    // up and down from the middle), data-gap (space between bars as a share
    // of a bar's width, default 0.6).
    var DEFAULTS = { bars: 5, speed: 360, gap: 0.6 };
    var BAR = 0.28, HEIGHT = 1.6; // em
    var STEPS = 10;               // random levels per loop

    var css =
      '.eqb{display:inline-flex;align-items:flex-end;height:' + HEIGHT + 'em}' +
      '.eqb-mid{align-items:center}' +
      // The gradient is sized to the full height, so taller bars reveal more
      // of it, like a real level meter.
      '.eqb-bar{width:' + BAR + 'em;min-height:' + BAR + 'em;border-radius:' + BAR / 2 + 'em;background:currentColor;' +
        'background-size:100% ' + HEIGHT + 'em;background-position:50% 100%}' +
      '.eqb-mid .eqb-bar{background-position:50% 50%}';
    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(12, +d.bars || DEFAULTS.bars));
      var gap = d.gap != null ? Math.max(0, +d.gap) : DEFAULTS.gap;
      var colors = d.colors ? d.colors.split(',') : [];
      var mirror = d.mirror != null && d.mirror !== 'false';
      el.classList.add('eqb');
      if (mirror) el.classList.add('eqb-mid');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.style.gap = BAR * gap + 'em';
      el.textContent = '';
      var bg = colors.length > 1
        ? 'linear-gradient(to top,' + colors[0].trim() + ',' + colors[1].trim() + ')'
        : colors[0] ? colors[0].trim() : '';
      var bars = [];
      for (var i = 0; i < n; i++) {
        var bar = document.createElement('span');
        bar.className = 'eqb-bar';
        if (bg) bar.style.background = bg;
        if (bg && mirror && colors.length > 1) {
          bar.style.background = 'linear-gradient(to top,' + colors[0].trim() + ',' + colors[1].trim() + ',' + colors[0].trim() + ')';
        }
        bar.setAttribute('aria-hidden', 'true');
        el.appendChild(bar);
        bars.push(bar);
      }
      // A resting shape: tall in the middle, shorter at the edges.
      function rest(i) { return 0.3 + 0.5 * Math.sin(((i + 0.5) / n) * Math.PI); }
      bars.forEach(function (b, i) { b.style.height = rest(i) * 100 + '%'; });
      if (reduced()) return null;

      var speed = +d.speed || DEFAULTS.speed;
      // Each bar gets its own run of random levels and timings, so they never
      // move in lockstep. The last level matches the first, so the loop is seamless.
      return bars.map(function (b, i) {
        var keys = [];
        var start = rest(i);
        for (var k = 0; k < STEPS; k++) {
          var to = k === STEPS - 1 ? start : (k % 2 ? anime.utils.random(22, 48) : anime.utils.random(55, 100)) / 100;
          to = to * 100 + '%';
          keys.push({ to: to, duration: speed * anime.utils.random(70, 140) / 100, ease: k % 2 ? 'in(1.6)' : 'out(2)' });
        }
        return anime.animate(b, { height: keys, loop: true, autoplay: false });
      });
    }

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

<!-- a "now playing" marker: -->
<span data-equaliser-bars data-bars="4" aria-hidden="true" style="font-size: 12px"></span> Now playing