← Charts

Population Pyramid

Two bar charts back to back. Bars grow outwards on both sides of a centre column of age bands, row by row from the bottom up, while their figures count up at the tips. The left side leads and the right follows a beat behind.

90+80–8970–7960–6950–5940–4930–3920–2910–190–9

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-population-pyramid to a container with a height, with one child per row from the top: its text is the centre label, and data-left and data-right the two figures.

Options

Add any of these attributes to the same element.

data-left-name / data-right-nameNames for the two sides, shown as a legend.
data-left-color / data-right-colorBar colours. Default purple and pink.
data-maxEnd of the scale on each side. Default the largest figure.
data-prefix / data-suffixText around the figures, e.g. %.
data-valuesfalse hides the figures.
data-durationTime for each bar to grow. Default 800 ms.
data-staggerGap between rows. Default 70 ms.
data-loopPull the bars back in and grow them again.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Population Pyramid: two bar charts back to back. Bars grow outwards on
    // both sides of a centre column of labels, row by row from the bottom up,
    // while their figures count up at the tips.
    // Usage:
    //   <div data-population-pyramid data-left-name="Men" data-right-name="Women" style="height:280px">
    //     <span data-left="2.1" data-right="2.9">80+</span>
    //     <span data-left="6.3" data-right="6.4">40–59</span>
    //     <span data-left="6.0" data-right="5.7">0–19</span>
    //   </div>
    // Each child is one row, top to bottom: its text is the centre label,
    // data-left and data-right the two figures.
    // Optional: data-left-name / data-right-name (shown as a legend),
    // data-left-color / data-right-color (defaults purple and pink),
    // data-max (end of the scale on each side, default the largest figure),
    // data-prefix / data-suffix (around the figures), data-values="false"
    // (hide the figures), data-duration (ms for each bar to grow, default 800),
    // data-stagger (gap between rows, default 70), data-loop (pull the bars
    // back in and grow them again).
    var DEFAULTS = { duration: 800, stagger: 70, hold: 2600, left: '#a855f7', right: '#ec4899' };

    var css =
      '.ppy{display:flex;flex-direction:column;font-size:11px;font-variant-numeric:tabular-nums;line-height:1}' +
      '.ppy-key{display:flex;justify-content:space-between;font-weight:700;font-size:12px;margin-bottom:8px}' +
      '.ppy-key span{display:inline-flex;align-items:center;gap:6px}' +
      '.ppy-key i{width:10px;height:10px;border-radius:3px}' +
      '.ppy-rows{flex:1;min-height:0;display:grid;grid-template-columns:1fr auto 1fr;grid-auto-rows:minmax(0,1fr);row-gap:2px}' +
      '.ppy-row{display:contents}' +
      '.ppy-lab{padding:0 8px;min-width:3.6em;display:flex;align-items:center;justify-content:center;opacity:.7;white-space:nowrap}' +
      '.ppy-side{position:relative;display:flex;align-items:stretch}' +
      '.ppy-l{justify-content:flex-end}' +
      '.ppy-track{position:relative;width:100%;display:flex}' +
      '.ppy-l .ppy-track{justify-content:flex-end}' +
      '.ppy-bar{position:relative;height:100%;width:0}' +
      '.ppy-l .ppy-bar{border-radius:3px 0 0 3px}' +
      '.ppy-r .ppy-bar{border-radius:0 3px 3px 0}' +
      '.ppy-val{position:absolute;top:50%;transform:translateY(-50%);font-weight:600;white-space:nowrap}' +
      '.ppy-l .ppy-val{right:100%;margin-right:5px}' +
      '.ppy-r .ppy-val{left:100%;margin-left:5px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
    function div(cls, parent) {
      var n = document.createElement('div');
      n.className = cls;
      if (parent) parent.appendChild(n);
      return n;
    }

    function setup(el) {
      var d = el.dataset;
      var rows = [];
      Array.prototype.forEach.call(el.children, function (ch) {
        var a = parseFloat(ch.getAttribute('data-left')), b = parseFloat(ch.getAttribute('data-right'));
        if (isNaN(a) || isNaN(b)) return;
        rows.push({ label: ch.textContent.trim(), l: Math.max(0, a), r: Math.max(0, b) });
      });
      var hi = Math.max.apply(null, rows.map(function (r) { return Math.max(r.l, r.r); })) || 1;
      var max = +d.max || hi;
      var dec = rows.some(function (r) { return r.l % 1 || r.r % 1; }) ? 1 : 0;
      var pre = d.prefix || '', suf = d.suffix || '';
      var show = d.values !== 'false';
      var lc = d.leftColor || DEFAULTS.left, rc = d.rightColor || DEFAULTS.right;
      var ln = d.leftName || '', rn = d.rightName || '';
      var fmt = function (v) { return pre + v.toFixed(dec) + suf; };
      el.classList.add('ppy');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Population pyramid. ' + rows.map(function (r) {
        return r.label + ': ' + (ln || 'left') + ' ' + fmt(r.l) + ', ' + (rn || 'right') + ' ' + fmt(r.r);
      }).join('; ') + '.');
      el.innerHTML = '';
      if (ln || rn) {
        var key = div('ppy-key', el);
        key.setAttribute('aria-hidden', 'true');
        key.innerHTML = '<span><i style="background:' + lc + '"></i>' + ln + '</span><span>' + rn + '<i style="background:' + rc + '"></i></span>';
      }
      var box = div('ppy-rows', el);
      box.setAttribute('aria-hidden', 'true');
      // Leave room at the tips for the figures.
      var room = show ? (Math.max.apply(null, rows.map(function (r) { return Math.max(fmt(r.l).length, fmt(r.r).length); })) * 6.6 + 8) : 2;
      rows.forEach(function (r) {
        var row = div('ppy-row', box);
        ['l', 'r'].forEach(function (s) {
          var side = div('ppy-side ppy-' + s);
          var track = div('ppy-track', side);
          track.style[s === 'l' ? 'marginLeft' : 'marginRight'] = room + 'px';
          var bar = div('ppy-bar', track);
          bar.style.background = s === 'l' ? lc : rc;
          r[s + 'Bar'] = bar;
          if (show) { r[s + 'Val'] = div('ppy-val', bar); }
          if (s === 'l') row.appendChild(side);
          else {
            var lab = div('ppy-lab', row);
            lab.textContent = r.label;
            row.appendChild(side);
          }
        });
      });
      var c = { el: el, rows: rows, max: max, fmt: fmt, k: rows.map(function () { return 0; }) };
      el._ppy = c;
      render(c);
      return c;
    }

    function ease(x) { return 1 - Math.pow(1 - x, 4); }

    // Row i at k (0–1): the left bar leads, the right follows a moment later.
    function render(c) {
      c.rows.forEach(function (r, i) {
        var k = c.k[i];
        var kl = ease(clamp01(k / 0.85)), kr = ease(clamp01((k - 0.15) / 0.85));
        r.lBar.style.width = (r.l / c.max * 100 * kl) + '%';
        r.rBar.style.width = (r.r / c.max * 100 * kr) + '%';
        if (r.lVal) {
          r.lVal.textContent = c.fmt(r.l * kl);
          r.rVal.textContent = c.fmt(r.r * kr);
          r.lVal.style.opacity = clamp01(kl * 3);
          r.rVal.style.opacity = clamp01(kr * 3);
        }
      });
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var n = c.rows.length;
      c.k = c.rows.map(function () { return 0; });
      render(c);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      // Build from the bottom row up.
      c.rows.forEach(function (r, i) {
        var s = { k: 0 };
        tl.add(s, { k: 1, duration: dur / 0.85, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, (n - 1 - i) * stagger);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 600, ease: 'in(2)',
              onUpdate: function () { c.k = c.k.map(function () { return o.k; }); render(c); },
              onComplete: function () { setTimeout(function () { play(c); }, 250); }
            });
          }, 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._ppy);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-population-pyramid]').forEach(function (el) {
        if (el._ppy) return;
        var c = setup(el);
        if (reduced()) { c.k = c.k.map(function () { return 1; }); render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-population-pyramid data-left-name="Men" data-right-name="Women"
     data-suffix="%" style="height: 280px">
  <span data-left="2.2" data-right="3.2">80+</span>
  <span data-left="9.4" data-right="10.0">60–79</span>
  <span data-left="12.8" data-right="13.0">40–59</span>
  <span data-left="13.2" data-right="13.1">20–39</span>
  <span data-left="11.7" data-right="11.1">0–19</span>
</div>