← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-bubble-chart to a container with a height, with one child per bubble: its text is the label and data-value its size.

Options

Add any of these attributes to the same element.

data-colorOn a bubble: its colour. Default a purple, pink, blue… set.
data-prefix / data-suffixText around the values, e.g. $.
data-valuesfalse shows the labels only.
data-fillHow much of the box the bubbles cover, 0–1. Default 0.5.
data-staggerGap between bubbles starting to grow. Default 90 ms.
data-loopShrink 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.

1 · Snippet
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>
2 · Your HTML
<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>