← Charts

Stacked Bar Build

A stacked bar chart that builds one series at a time: the bottom segment grows on every bar, then the next stacks on top, while the legend lights up each series as it arrives. The totals ride up on top and count to their final values.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stacked-bar-build to a container with a height, with one child per bar carrying data-label and data-values (one value per series, bottom first).

Options

Add any of these attributes to the same element.

data-seriesComma-separated series names, shown as a legend.
data-colorsComma-separated colours, one per series. Default purple, pink, blue…
data-maxTop of the scale. Default: the tallest bar.
data-prefix / data-suffixText around the totals, e.g. $.
data-totalsfalse hides the totals above the bars.
data-legendfalse hides the legend.
data-durationTime for each series to grow. Default 600 ms.
data-staggerGap between bars. Default 60 ms.
data-loopTake the stacks down and build them again.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stacked Bar Build: a stacked bar chart that builds one series at a time.
    // The first segment grows on every bar, then the next stacks on top of it,
    // and so on, while the legend lights up the series being added. Totals
    // count up above the bars once they're complete.
    // Usage:
    //   <div data-stacked-bar-build data-series="Direct,Social,Email" style="height:240px">
    //     <div data-label="Q1" data-values="30,20,10"></div>
    //     <div data-label="Q2" data-values="40,25,15"></div>
    //   </div>
    // Each child is one bar, with one value per series (bottom first).
    // Optional: data-colors="#a855f7,#ec4899,…" (one per series), data-max
    // (top of the scale, default the tallest bar), data-prefix / data-suffix
    // (around the totals), data-totals="false" (no totals), data-legend="false"
    // (no legend), data-duration (ms for each series, default 600),
    // data-stagger (ms between bars, default 60), data-loop (take it down and
    // build it again).
    var DEFAULTS = { duration: 600, stagger: 60, hold: 2600 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];

    var css =
      '.sbb{display:flex;flex-direction:column;gap:10px;font-variant-numeric:tabular-nums}' +
      '.sbb-plot{position:relative;flex:1;min-height:0;container-type:size;display:flex;align-items:flex-end;gap:6%;padding:0 2%;' +
      'background:linear-gradient(currentColor,currentColor) bottom/100% 1px no-repeat;color:inherit}' +
      '.sbb-plot::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.08;' +
      'background:repeating-linear-gradient(to top,currentColor 0 1px,transparent 1px 25%)}' +
      '.sbb-col{position:relative;flex:1;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:stretch}' +
      '.sbb-bar{position:relative;display:flex;flex-direction:column-reverse;border-radius:6px 6px 0 0;overflow:hidden}' +
      '.sbb-seg + .sbb-seg{box-shadow:inset 0 -1.5px 0 rgba(255,255,255,.7)}' +
      '.sbb-seg{flex:none;height:0}' +
      '.sbb-total{display:block;text-align:center;font-size:12px;font-weight:700;line-height:1;padding-bottom:5px;opacity:0;white-space:nowrap}' +
      '.sbb-labels{display:flex;gap:6%;padding:0 2%;font-size:12px;opacity:.65}' +
      '.sbb-labels span{flex:1;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.sbb-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 14px;font-size:12px;font-weight:600}' +
      '.sbb-key{display:inline-flex;align-items:center;gap:6px;opacity:.4;transition:opacity .3s}' +
      '.sbb-key.sbb-on{opacity:1}' +
      '.sbb-key i{width:10px;height:10px;border-radius:3px;background:var(--sbb-c)}';
    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 series = d.series ? d.series.split(',') : [];
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      var bars = Array.prototype.slice.call(el.children).map(function (b) {
        return { label: b.getAttribute('data-label') || '', values: (b.getAttribute('data-values') || '').split(',').map(Number) };
      });
      var count = Math.max.apply(null, bars.map(function (b) { return b.values.length; }));
      var totals = bars.map(function (b) { return b.values.reduce(function (a, v) { return a + v; }, 0); });
      var max = +d.max || Math.max.apply(null, totals) || 1;
      var showTotals = d.totals !== 'false';
      var pre = d.prefix || '', suf = d.suffix || '';

      el.classList.add('sbb');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Stacked bar chart. ' + bars.map(function (b, i) {
        return b.label + ': ' + b.values.map(function (v, j) { return (series[j] ? series[j] + ' ' : '') + v; }).join(', ') + ', total ' + totals[i];
      }).join('. '));

      var html = '<div class="sbb-plot" aria-hidden="true">';
      bars.forEach(function (b, i) {
        html += '<div class="sbb-col">' +
          (showTotals ? '<span class="sbb-total">' + esc(pre) + '0' + esc(suf) + '</span>' : '') +
          '<div class="sbb-bar">';
        for (var j = 0; j < count; j++) {
          html += '<div class="sbb-seg" data-s="' + j + '" style="background:' + colors[j % colors.length] + '"></div>';
        }
        html += '</div></div>';
      });
      html += '</div><div class="sbb-labels" aria-hidden="true">' + bars.map(function (b) { return '<span>' + esc(b.label) + '</span>'; }).join('') + '</div>';
      if (series.length && d.legend !== 'false') {
        html += '<div class="sbb-legend" aria-hidden="true">' + series.map(function (s, j) {
          return '<span class="sbb-key" style="--sbb-c:' + colors[j % colors.length] + '"><i></i>' + esc(s) + '</span>';
        }).join('') + '</div>';
      }
      el.innerHTML = html;

      var c = { el: el, bars: bars, totals: totals, count: count, pre: pre, suf: suf };
      // segs[j] = the segment for series j on every bar; height is its share of that bar.
      c.segs = [];
      for (var j = 0; j < count; j++) {
        c.segs.push(Array.prototype.slice.call(el.querySelectorAll('.sbb-seg[data-s="' + j + '"]')));
      }
      // Heights are in cqh (hundredths of the plot's height), leaving headroom for the totals.
      var scale = showTotals ? 86 : 100;
      c.share = function (seg, i, j) { return ((bars[i].values[j] || 0) / max) * scale; };
      c.keys = Array.prototype.slice.call(el.querySelectorAll('.sbb-key'));
      c.labels = Array.prototype.slice.call(el.querySelectorAll('.sbb-total'));
      el._sbb = c;
      return c;
    }

    function finish(c) {
      c.segs.forEach(function (row, j) {
        row.forEach(function (seg, i) { seg.style.height = c.share(seg, i, j) + 'cqh'; });
      });
      c.keys.forEach(function (k) { k.classList.add('sbb-on'); });
      c.labels.forEach(function (l, i) { l.textContent = c.pre + c.totals[i].toLocaleString() + c.suf; l.style.opacity = 1; });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = +d.stagger || DEFAULTS.stagger;
      var step = duration + stagger * (c.bars.length - 1) * 0.6;
      var tl = anime.createTimeline();
      c.keys.forEach(function (k) { k.classList.remove('sbb-on'); });

      c.segs.forEach(function (row, j) {
        tl.call(function () {
          c.keys.forEach(function (k, n) { k.classList.toggle('sbb-on', n <= j); });
        }, j * step);
        tl.add(row, {
          height: function (seg, i) { return ['0cqh', c.share(seg, i, j) + 'cqh']; },
          duration: duration,
          delay: anime.stagger(stagger),
          ease: 'out(3)'
        }, j * step);
      });

      // Totals count up once the last series is on.
      var end = c.count * step;
      c.labels.forEach(function (l, i) {
        var n = { v: 0 };
        tl.add(l, { opacity: [0, 1], duration: 300 }, end - 200 + i * 40);
        tl.add(n, {
          v: c.totals[i], duration: 700, ease: 'out(3)',
          onUpdate: function () { l.textContent = c.pre + Math.round(n.v).toLocaleString() + c.suf; }
        }, end - 200 + i * 40);
      });

      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            anime.animate(c.labels, { opacity: 0, duration: 250 });
            // Take the segments off top first.
            var all = [];
            for (var j = c.count - 1; j >= 0; j--) all = all.concat(c.segs[j]);
            anime.animate(all, {
              height: '0cqh', duration: 380, ease: 'in(2)',
              delay: anime.stagger(18),
              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._sbb);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-stacked-bar-build]').forEach(function (el) {
        if (el._sbb) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-stacked-bar-build data-series="Direct,Social,Email"
     style="height: 260px">
  <div data-label="Q1" data-values="30,20,10"></div>
  <div data-label="Q2" data-values="40,25,15"></div>
  <div data-label="Q3" data-values="35,30,22"></div>
  <div data-label="Q4" data-values="50,34,26"></div>
</div>