Bubble Chart
A packed bubble chart where each bubble’s area matches its value. They grow from nothing, biggest first, and jostle each other into a tight cluster, nudging their neighbours aside as they swell.
RentFoodTravelBillsFunSavingsHealthGiftsBooks
How to use it
- Paste the snippet just before
</head>. - Add
data-bubble-chartto a container with a height, with one child per bubble: its text is the label anddata-valueits size.
Options
Add any of these attributes to the same element.
data-color | On a bubble: its colour. Default a purple, pink, blue… set. |
data-prefix / data-suffix | Text around the values, e.g. $. |
data-values | false shows the labels only. |
data-fill | How much of the box the bubbles cover, 0–1. Default 0.5. |
data-stagger | Gap between bubbles starting to grow. Default 90 ms. |
data-loop | Shrink away and grow again every few seconds. |
Bubbles too small for their label just show their colour; screen readers get every label and value as a list. The bubbles repack when the box changes size.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bubble Chart: a packed bubble chart. Each bubble's area matches its
// value; they grow from nothing, biggest first, and jostle each other
// into a tight cluster, nudging their neighbours aside as they swell.
// Usage:
// <div data-bubble-chart style="height:280px">
// <span data-value="420">Rent</span>
// <span data-value="260">Food</span>
// <span data-value="90">Travel</span>
// </div>
// Each child is one bubble: its text is the label, data-value its size.
// Optional: data-color on a bubble (default a purple, pink, blue… set),
// data-prefix / data-suffix (around the values, e.g. "$"),
// data-values="false" (labels only), data-fill (how much of the box the
// bubbles cover, 0–1, default 0.5), data-stagger (ms between bubbles,
// default 90), data-loop (shrink away and grow again every few seconds).
var DEFAULTS = { fill: 0.5, stagger: 90, hold: 3200, gap: 3 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6', '#8b5cf6', '#06b6d4'];
var css =
'.bbc{position:relative;overflow:hidden}' +
'.bbc-b{position:absolute;left:0;top:0;display:grid;place-content:center;border-radius:50%;color:#fff;text-align:center;line-height:1.1;' +
'background:radial-gradient(circle at 32% 28%,rgba(255,255,255,.35),transparent 60%),var(--bbc-c);box-shadow:inset 0 -6px 14px rgba(0,0,0,.12);will-change:transform}' +
'.bbc-b b{display:block;font-weight:700;white-space:nowrap}' +
'.bbc-b small{display:block;font-weight:500;opacity:.85;font-variant-numeric:tabular-nums}' +
'.bbc-b.bbc-tiny b,.bbc-b.bbc-tiny small,.bbc-b.bbc-small small{display:none}' +
'.bbc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function setup(el) {
var d = el.dataset;
var pre = d.prefix || '', suf = d.suffix || '';
var items = Array.prototype.slice.call(el.children).map(function (b, i) {
return {
label: b.textContent.trim(),
value: +b.getAttribute('data-value') || 0,
color: b.getAttribute('data-color') || PALETTE[i % PALETTE.length]
};
});
// Biggest first: they grow first and end up near the middle.
items.sort(function (a, b) { return b.value - a.value; });
el.classList.add('bbc');
el.innerHTML = '<ul class="bbc-sr">' + items.map(function (it) {
return '<li>' + esc(it.label) + ': ' + esc(pre) + it.value.toLocaleString() + esc(suf) + '</li>';
}).join('') + '</ul>';
items.forEach(function (it) {
var b = document.createElement('div');
b.className = 'bbc-b';
b.setAttribute('aria-hidden', 'true');
b.style.setProperty('--bbc-c', it.color);
b.innerHTML = '<b>' + esc(it.label) + '</b>' +
(d.values === 'false' ? '' : '<small>' + esc(pre) + it.value.toLocaleString() + esc(suf) + '</small>');
el.appendChild(b);
it.el = b;
it.g = 0; // growth, 0–1
it.x = it.y = it.vx = it.vy = 0;
});
var c = { el: el, items: items, running: false, visible: false, raf: 0, still: 0 };
measure(c);
scatter(c);
el._bbc = c;
return c;
}
// Work out each bubble's full radius from the box size.
function measure(c) {
var d = c.el.dataset;
c.w = c.el.clientWidth;
c.h = c.el.clientHeight;
var fill = +d.fill || DEFAULTS.fill;
var total = c.items.reduce(function (a, it) { return a + it.value; }, 0) || 1;
var area = c.w * c.h * fill;
var k = Math.sqrt(area / Math.PI / total);
var biggest = Math.sqrt(c.items[0] ? c.items[0].value : 0) * k;
var cap = Math.min(c.w, c.h) * 0.46;
if (biggest > cap) k *= cap / biggest;
c.items.forEach(function (it) {
it.r = Math.sqrt(it.value) * k;
it.el.classList.toggle('bbc-tiny', it.r < 18);
it.el.classList.toggle('bbc-small', it.r >= 18 && it.r < 30);
it.el.style.fontSize = Math.max(10, Math.min(18, it.r / 3.2)) + 'px';
});
}
// Start every bubble near the middle, a little apart.
function scatter(c) {
c.items.forEach(function (it, i) {
var a = i * 2.4, dist = 6 + i * 4;
it.x = c.w / 2 + Math.cos(a) * dist * (c.w / Math.max(1, c.h));
it.y = c.h / 2 + Math.sin(a) * dist;
it.vx = it.vy = 0;
});
}
function step(c) {
var items = c.items, n = items.length, cx = c.w / 2, cy = c.h / 2;
// Pull towards the middle, more gently sideways so the cluster fills a wide box.
var gx = 0.012 * Math.min(1, c.h / Math.max(1, c.w)), gy = 0.012;
var moved = 0;
items.forEach(function (it) {
it.vx = (it.vx + (cx - it.x) * gx) * 0.82;
it.vy = (it.vy + (cy - it.y) * gy) * 0.82;
it.x += it.vx;
it.y += it.vy;
});
// Push overlapping bubbles apart.
for (var pass = 0; pass < 3; pass++) {
for (var i = 0; i < n; i++) {
var a = items[i], ra = a.r * a.g;
for (var j = i + 1; j < n; j++) {
var b = items[j], rb = b.r * b.g;
var dx = b.x - a.x, dy = b.y - a.y, dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
var min = ra + rb + DEFAULTS.gap * Math.min(a.g, b.g);
if (dist < min) {
var push = (min - dist) / dist / 2;
// The bigger bubble gets pushed less.
var wa = rb * rb / (ra * ra + rb * rb || 1), wb = 1 - wa;
a.x -= dx * push * wa * 1.2; a.y -= dy * push * wa * 1.2;
b.x += dx * push * wb * 1.2; b.y += dy * push * wb * 1.2;
}
}
}
}
items.forEach(function (it) {
var r = it.r * it.g;
it.x = Math.max(r, Math.min(c.w - r, it.x));
it.y = Math.max(r, Math.min(c.h - r, it.y));
moved += Math.abs(it.vx) + Math.abs(it.vy);
});
return moved;
}
function render(c) {
c.items.forEach(function (it) {
var r = Math.max(0, it.r * it.g);
it.el.style.width = it.el.style.height = (r * 2).toFixed(1) + 'px';
it.el.style.transform = 'translate(' + (it.x - r).toFixed(1) + 'px,' + (it.y - r).toFixed(1) + 'px)';
it.el.style.opacity = it.g > 0.02 ? 1 : 0;
});
}
function tick(c) {
c.raf = 0;
if (!c.visible) return;
var moved = step(c);
render(c);
// Sleep once everything has settled and nothing is growing.
c.still = moved < 0.05 * c.items.length && !c.growing ? c.still + 1 : 0;
if (c.still < 30) c.raf = requestAnimationFrame(function () { tick(c); });
}
function wake(c) {
c.still = 0;
if (!c.raf && c.visible) c.raf = requestAnimationFrame(function () { tick(c); });
}
function grow(c) {
var d = c.el.dataset;
c.growing = true;
wake(c);
anime.animate(c.items, {
g: [0, 1],
duration: 1300,
delay: anime.stagger(+d.stagger || DEFAULTS.stagger),
ease: 'outElastic(1, .55)',
onUpdate: function () { wake(c); },
onComplete: function () {
c.growing = false;
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
c.growing = true;
wake(c);
anime.animate(c.items, {
g: 0, duration: 450, ease: 'in(2)', delay: anime.stagger(40, { reversed: true }),
onUpdate: function () { wake(c); },
onComplete: function () { scatter(c); grow(c); }
});
}, DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._bbc;
c.visible = e.isIntersecting;
if (!c.visible) return;
if (!c.started) { c.started = true; grow(c); }
else wake(c);
});
}, { threshold: 0.2 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._bbc;
if (!c || (c.w === c.el.clientWidth && c.h === c.el.clientHeight)) return;
measure(c);
if (c.still === -1) { settle(c); return; }
wake(c);
});
});
document.querySelectorAll('[data-bubble-chart]').forEach(function (el) {
if (el._bbc) return;
var c = setup(el);
ro.observe(el);
if (reduced()) { c.still = -1; settle(c); return; }
render(c);
io.observe(el);
});
}
// Reduced motion: work out the final layout straight away and show it.
function settle(c) {
c.items.forEach(function (it) { it.g = 1; });
for (var i = 0; i < 400; i++) step(c);
render(c);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bubble-chart data-prefix="$" style="height: 300px">
<span data-value="1200">Rent</span>
<span data-value="520">Food</span>
<span data-value="340">Travel</span>
<span data-value="260">Bills</span>
</div>