Bar Chart Race
Horizontal bars grow and overtake each other as the values change over time, sliding into their new places while the year ticks over in the corner. Great for showing who pulled ahead, and when.
How to use it
- Paste the snippet just before
</head>. - Add
data-bar-chart-raceanddata-frames="2021,2022,…"to a container. - Add one child per bar with
data-labelanddata-values, a value for each frame.
Options
Add any of these attributes to the same element.
data-color | On a bar: its colour. Default a purple, pink, blue… set. |
data-top | Show only the leading few bars. |
data-row | Height of each bar’s row. Default 36 px. |
data-step | Time for each frame. Default 1400 ms. |
data-prefix / data-suffix | Around the values, e.g. $ or k. |
data-caption | What the chart shows, for screen readers. |
data-loop | Rewind and race again. |
A Pause button is added so anyone can stop it, screen readers get every value as a table, and with reduced motion it simply shows the final standings.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bar Chart Race: horizontal bars that grow and overtake each other as
// the values change over time, sliding into their new places while the
// period ticks over in the corner.
// Usage:
// <div data-bar-chart-race data-frames="2021,2022,2023,2024"
// data-caption="Weekly visitors">
// <div data-label="Blog" data-values="120,340,560,610"></div>
// <div data-label="Shop" data-values="300,380,420,880"></div>
// <div data-label="Docs" data-values="80,260,640,700"></div>
// </div>
// Each child is one bar, with a value for each period in data-frames.
// Optional: data-color on a bar (default: a purple, pink, blue… set),
// data-top (show only the leading few), data-row (px per bar, default 36),
// data-step (ms per period, default 1400), data-prefix / data-suffix
// (around the values, e.g. "$"), data-caption (what it shows, for screen
// readers), data-loop (rewind and race again).
// Screen readers get every value as a table. A Pause button lets anyone
// stop it; with reduced motion it simply shows the final standings.
var DEFAULTS = { row: 36, step: 1400, hold: 2400 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
var css =
'.bcx{position:relative;width:100%;text-align:left;font-variant-numeric:tabular-nums}' +
'.bcx-plot{position:relative;overflow:hidden}' +
'.bcx-row{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:10px}' +
'.bcx-name{flex:0 0 26%;overflow:hidden;font-size:.9em;font-weight:600;text-align:right;white-space:nowrap;text-overflow:ellipsis}' +
'.bcx-track{position:relative;flex:1;display:flex;align-items:center;height:72%;min-width:0}' +
'.bcx-bar{height:100%;min-width:4px;border-radius:0 7px 7px 0}' +
'.bcx-val{padding-left:8px;font-size:.85em;font-weight:600;white-space:nowrap;opacity:.8}' +
'.bcx-period{position:absolute;right:4px;bottom:0;font-size:2.6em;font-weight:800;letter-spacing:-.04em;line-height:1;opacity:.14;pointer-events:none}' +
'.bcx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}' +
':where(.bcx-btn){display:block;margin:0 0 10px auto;font:inherit;font-size:13px;font-weight:600;padding:5px 12px;' +
'border:1px solid rgba(15,15,30,.15);border-radius:99px;background:#fff;color:inherit;cursor:pointer}' +
':where(.bcx-btn):focus-visible{outline:2px solid #6a0dad;outline-offset:2px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function build(el) {
var d = el.dataset;
var frames = (d.frames || '').split(',').map(function (f) { return f.trim(); });
var items = Array.from(el.children).map(function (c, i) {
var vals = (c.dataset.values || '').split(',').map(Number);
return { label: c.dataset.label || '', color: c.dataset.color || PALETTE[i % PALETTE.length], vals: vals, rank: -1 };
});
var F = Math.max(1, frames.length, items.reduce(function (m, it) { return Math.max(m, it.vals.length); }, 0));
while (frames.length < F) frames.push(String(frames.length + 1));
items.forEach(function (it) { while (it.vals.length < F) it.vals.push(it.vals[it.vals.length - 1] || 0); });
var top = Math.min(items.length, +d.top || items.length);
var row = +d.row || DEFAULTS.row;
var pre = d.prefix || '', suf = d.suffix || '';
var fmt = function (v) { return pre + Math.round(v).toLocaleString() + suf; };
var inThumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('bcx');
el.innerHTML =
'<table class="bcx-sr"><caption>' + esc(d.caption || 'Bar chart race') + '</caption><thead><tr><th scope="col">Item</th>' +
frames.map(function (f) { return '<th scope="col">' + esc(f) + '</th>'; }).join('') + '</tr></thead><tbody>' +
items.map(function (it) {
return '<tr><th scope="row">' + esc(it.label) + '</th>' + it.vals.map(function (v) { return '<td>' + esc(fmt(v)) + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>' +
'<div class="bcx-plot" aria-hidden="true" style="height:' + top * row + 'px"><div class="bcx-period"></div></div>';
var plot = el.querySelector('.bcx-plot');
items.forEach(function (it) {
var r = document.createElement('div');
r.className = 'bcx-row';
r.style.height = row + 'px';
r.innerHTML = '<span class="bcx-name"></span><span class="bcx-track"><span class="bcx-bar"></span><span class="bcx-val"></span></span>';
r.querySelector('.bcx-name').textContent = it.label;
r.querySelector('.bcx-bar').style.background = it.color;
plot.appendChild(r);
it.row = r;
it.bar = r.querySelector('.bcx-bar');
it.val = r.querySelector('.bcx-val');
});
var c = { el: el, frames: frames, items: items, top: top, rowH: row, fmt: fmt, state: { f: 0 }, anim: null, period: plot.querySelector('.bcx-period'), btn: null, phase: 'idle' };
if (!inThumb && F > 1) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bcx-btn';
btn.textContent = 'Pause';
btn.addEventListener('click', function () { press(c); });
el.insertBefore(btn, plot);
c.btn = btn;
}
return c;
}
function render(c, instant) {
var f = c.state.f, k = Math.min(Math.floor(f), c.frames.length - 1), t = f - k;
var vals = c.items.map(function (it) {
var a = it.vals[k], b = it.vals[Math.min(k + 1, it.vals.length - 1)];
return a + (b - a) * t;
});
var order = vals.map(function (v, i) { return i; }).sort(function (a, b) { return vals[b] - vals[a]; });
var lead = Math.max(vals[order[0]], 1e-9);
order.forEach(function (i, rank) {
var it = c.items[i], v = vals[i];
// The leader fills most of the track, leaving room for its number.
it.bar.style.width = Math.max(0, (v / lead) * 82) + '%';
it.val.textContent = c.fmt(v);
if (rank !== it.rank) {
var y = Math.min(rank, c.top) * c.rowH;
var props = { y: y, opacity: rank < c.top ? 1 : 0 };
if (instant || it.rank < 0) anime.utils.set(it.row, props);
else anime.animate(it.row, Object.assign(props, { duration: 520, ease: 'inOut(3)' }));
it.rank = rank;
}
});
c.period.textContent = c.frames[Math.min(c.frames.length - 1, Math.round(f))];
}
function label(c, text) { if (c.btn) c.btn.textContent = text; }
function race(c) {
var d = c.el.dataset, F = c.frames.length;
var looping = d.loop !== undefined && d.loop !== 'false';
c.phase = 'racing';
label(c, 'Pause');
c.anim = anime.animate(c.state, {
f: [0, F - 1], duration: (F - 1) * (+d.step || DEFAULTS.step), ease: 'linear', onUpdate: function () { render(c); },
onComplete: function () {
c.phase = 'done';
if (!looping) return label(c, 'Replay');
label(c, 'Pause');
c.wait = setTimeout(rewind, DEFAULTS.hold);
}
});
// Rewind quickly, then race again.
function rewind() {
c.phase = 'racing';
c.anim = anime.animate(c.state, { f: 0, duration: 900, ease: 'inOut(2)', onUpdate: function () { render(c); }, onComplete: function () { c.wait = setTimeout(function () { race(c); }, 500); } });
}
}
function press(c) {
if (c.phase === 'done' && c.btn.textContent === 'Replay') return race(c);
if (c.phase === 'paused') {
c.phase = c.resume;
label(c, 'Pause');
if (c.anim && !c.anim.completed) c.anim.play();
else race(c);
return;
}
c.resume = c.phase;
c.phase = 'paused';
clearTimeout(c.wait);
if (c.anim) c.anim.pause();
label(c, 'Play');
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
race(e.target._bcx);
});
}, { threshold: 0.4 });
document.querySelectorAll('[data-bar-chart-race]').forEach(function (el) {
if (el._bcx) return;
var c = (el._bcx = build(el));
if (reduced || c.frames.length < 2) {
// The final standings, still.
c.state.f = c.frames.length - 1;
render(c, true);
if (c.btn) c.btn.remove();
return;
}
render(c, true);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bar-chart-race data-frames="2022,2023,2024"
data-caption="Weekly visitors">
<div data-label="Blog" data-values="120,340,610"></div>
<div data-label="Shop" data-values="300,380,880"></div>
<div data-label="Docs" data-values="80,640,700"></div>
</div>