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
- Paste the snippet just before
</head>. - Add an empty
<div data-equaliser-bars>where the loader should appear.
Options
Add any of these attributes to the same element.
data-bars | Number of bars, 3 to 12. Default 5. |
data-speed | Roughly how long each rise or fall takes. Default 360 ms. |
data-colors | One colour, or two for a gradient from bottom to top. |
data-mirror | Bars grow up and down from the middle, like a sound wave. |
data-gap | Space 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.
<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><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