← Charts

Treemap Build

One rectangle splits in two, then each half splits again, each piece sliding aside to reveal the next, until every item has a rectangle whose area matches its value. Then the labels fade in.

RentFoodTravelBillsFunSavingsHealthGiftsBooks

How to use it

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

Options

Add any of these attributes to the same element.

data-colorOn a rectangle: its colour. Default a purple, pink, blue… set.
data-prefix / data-suffixText around the values, e.g. $.
data-valuesfalse shows the labels only.
data-stepTime for each round of splitting. Default 480 ms.
data-loopFold back into one rectangle and split again.

Each split cuts across the longer side, so the pieces stay close to square. Labels too big for their rectangle are left off; screen readers get every item with its share as a list.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Treemap Build: one rectangle splits in two, then each half splits again,
    // and again, each piece sliding aside to reveal the next, until every item
    // has a rectangle whose area matches its value. Then the labels fade in.
    // Usage:
    //   <div data-treemap-build style="height:260px">
    //     <span data-value="420">Rent</span>
    //     <span data-value="260">Food</span>
    //     <span data-value="90">Travel</span>
    //   </div>
    // Each child is one rectangle: its text is the label, data-value its size.
    // Optional: data-color on a rectangle (default a purple, pink, blue… set),
    // data-prefix / data-suffix (around the values, e.g. "$"),
    // data-values="false" (labels only), data-step (ms for each round of
    // splitting, default 480), data-loop (fold back up and split again).
    var DEFAULTS = { step: 480, hold: 3000 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6', '#8b5cf6', '#06b6d4'];

    var css =
      '.tmb{position:relative}' +
      '.tmb-leaf{position:absolute;box-sizing:border-box;padding:2px}' +
      '.tmb-leaf>div{position:relative;width:100%;height:100%;border-radius:6px;background:var(--tmb-c);color:#fff;overflow:hidden;' +
      'box-shadow:inset 0 0 0 1px rgba(255,255,255,.15)}' +
      '.tmb-text{position:absolute;left:8px;top:6px;right:6px;text-align:left;line-height:1.2;opacity:0}' +
      '.tmb-text b{display:block;font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.tmb-text small{display:block;font-size:12px;opacity:.85;font-variant-numeric:tabular-nums}' +
      '.tmb-leaf.tmb-nov small,.tmb-leaf.tmb-none .tmb-text{display:none}' +
      '.tmb-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]; }); }

    // Split items (sorted, biggest first) into two runs,
    // cutting the rectangle across its longer side; repeat inside each part.
    // Records, for every item, its rectangle at each depth of the splitting.
    function partition(items, rect, depth, ratio) {
      items.forEach(function (it) { it.rects[depth] = rect; });
      if (items.length < 2) return depth;
      var total = items.reduce(function (a, it) { return a + it.value; }, 0);
      var wide = rect.w * ratio >= rect.h;
      var k = bestCut(items, rect, ratio).k;
      var a = items.slice(0, k), b = items.slice(k);
      var share = a.reduce(function (s, it) { return s + it.value; }, 0) / (total || 1);
      var ra = cut(rect, share, wide, true), rb = cut(rect, share, wide, false);
      return Math.max(partition(a, ra, depth + 1, ratio), partition(b, rb, depth + 1, ratio));
    }

    // One side of a rectangle cut at share f, across its longer side.
    function cut(rect, f, wide, first) {
      if (wide) return first ? { x: rect.x, y: rect.y, w: rect.w * f, h: rect.h } : { x: rect.x + rect.w * f, y: rect.y, w: rect.w * (1 - f), h: rect.h };
      return first ? { x: rect.x, y: rect.y, w: rect.w, h: rect.h * f } : { x: rect.x, y: rect.y + rect.h * f, w: rect.w, h: rect.h * (1 - f) };
    }

    // The cut whose finished pieces end up closest to square (summed over the
    // pieces), trying every way of cutting for up to 10 items; beyond that,
    // the cut that halves the total.
    function bestCut(items, rect, ratio) {
      var n = items.length;
      if (n < 2) {
        var w = rect.w * ratio, h = rect.h;
        return { cost: Math.max(w / (h || 1e-6), h / (w || 1e-6)) };
      }
      var total = items.reduce(function (s, it) { return s + it.value; }, 0) || 1;
      var wide = rect.w * ratio >= rect.h, run = 0, best = { cost: Infinity, k: 1 };
      for (var i = 1; i < n; i++) {
        run += items[i - 1].value;
        var f = run / total, cost;
        if (n > 10) cost = Math.abs(f - 0.5);
        else cost = bestCut(items.slice(0, i), cut(rect, f, wide, true), ratio).cost + bestCut(items.slice(i), cut(rect, f, wide, false), ratio).cost;
        if (cost < best.cost) best = { cost: cost, k: i };
      }
      return best;
    }

    // The rectangle an item has at a given depth (its own once it's been split off).
    function rectAt(it, depth) {
      var r = null;
      for (var dd = 0; dd <= depth; dd++) if (it.rects[dd]) r = it.rects[dd];
      return r;
    }

    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] };
      });
      items.sort(function (a, b) { return b.value - a.value; });
      var total = items.reduce(function (a, it) { return a + it.value; }, 0) || 1;

      el.classList.add('tmb');
      el.innerHTML = '<ul class="tmb-sr">' + items.map(function (it) {
        return '<li>' + esc(it.label) + ': ' + esc(pre) + it.value.toLocaleString() + esc(suf) + ' (' + Math.round((it.value / total) * 100) + '%)</li>';
      }).join('') + '</ul>';
      items.forEach(function (it) {
        var leaf = document.createElement('div');
        leaf.className = 'tmb-leaf';
        leaf.setAttribute('aria-hidden', 'true');
        leaf.style.setProperty('--tmb-c', it.color);
        leaf.innerHTML = '<div><div class="tmb-text"><b>' + esc(it.label) + '</b>' +
          (d.values === 'false' ? '' : '<small>' + esc(pre) + it.value.toLocaleString() + esc(suf) + '</small>') + '</div></div>';
        el.appendChild(leaf);
        it.el = leaf;
      });
      var c = { el: el, items: items, leaves: items.map(function (it) { return it.el; }), texts: Array.prototype.slice.call(el.querySelectorAll('.tmb-text')) };
      measure(c);
      el._tmb = c;
      return c;
    }

    function measure(c) {
      c.w = c.el.clientWidth;
      c.h = c.el.clientHeight;
      c.items.forEach(function (it) { it.rects = []; });
      c.depth = partition(c.items, { x: 0, y: 0, w: 100, h: 100 }, 0, c.w / (c.h || 1));
      // Hide labels that won't fit.
      c.items.forEach(function (it) {
        var r = it.rects[it.rects.length - 1], w = (r.w / 100) * c.w, h = (r.h / 100) * c.h;
        it.el.classList.toggle('tmb-none', w < 52 || h < 26);
        it.el.classList.toggle('tmb-nov', h < 44);
      });
    }

    function place(c, depth) {
      c.items.forEach(function (it) {
        var r = rectAt(it, depth);
        anime.utils.set(it.el, { left: r.x + '%', top: r.y + '%', width: r.w + '%', height: r.h + '%' });
      });
    }

    function play(c) {
      var d = c.el.dataset;
      var step = +d.step || DEFAULTS.step;
      var tl = anime.createTimeline();
      c.playing = true;
      if (c.w !== c.el.clientWidth || c.h !== c.el.clientHeight) measure(c);
      place(c, 0);
      anime.utils.set(c.texts, { opacity: 0 });
      tl.add(c.leaves, { opacity: [0, 1], scale: [0.92, 1], duration: 400, ease: 'out(3)' }, 0);
      for (var dd = 1; dd <= c.depth; dd++) {
        (function (dd) {
          tl.add(c.leaves, {
            left: function (el, i) { return rectAt(c.items[i], dd).x + '%'; },
            top: function (el, i) { return rectAt(c.items[i], dd).y + '%'; },
            width: function (el, i) { return rectAt(c.items[i], dd).w + '%'; },
            height: function (el, i) { return rectAt(c.items[i], dd).h + '%'; },
            duration: step, ease: 'inOut(3)'
          }, 250 + dd * step * 0.85);
        })(dd);
      }
      var end = 250 + (c.depth + 1) * step * 0.85;
      tl.add(c.texts, { opacity: [0, 1], translateY: [6, 0], duration: 400, delay: anime.stagger(50), ease: 'out(2)' }, end);
      tl.then(function () {
        c.playing = false;
        // The box changed size mid-split: snap to the new layout.
        if (c.dirty) { c.dirty = false; measure(c); place(c, c.depth); }
        if (d.loop === undefined || d.loop === 'false') return;
        setTimeout(function () {
          anime.animate(c.texts, { opacity: 0, duration: 250 });
          // Fold everything back into one rectangle.
          anime.animate(c.leaves, {
            left: '0%', top: '0%', width: '100%', height: '100%', opacity: 0,
            duration: 700, delay: 150, ease: 'inOut(3)',
            onComplete: function () { play(c); }
          });
        }, DEFAULTS.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._tmb);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._tmb;
          if (!c || (c.w === c.el.clientWidth && c.h === c.el.clientHeight)) return;
          // Mid-split, catch up once the split finishes.
          if (c.playing) { c.dirty = true; return; }
          measure(c);
          if (c.shown) place(c, c.depth);
        });
      });
      document.querySelectorAll('[data-treemap-build]').forEach(function (el) {
        if (el._tmb) return;
        var c = setup(el);
        ro.observe(el);
        c.shown = true;
        if (reduced()) { place(c, c.depth); anime.utils.set(c.texts, { opacity: 1 }); return; }
        place(c, 0);
        anime.utils.set(c.leaves, { opacity: 0 });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-treemap-build data-prefix="$" style="height: 280px">
  <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>