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
- Paste the snippet just before
</head>. - Add
data-population-pyramidto a container with a height, with one child per row from the top: its text is the centre label, anddata-leftanddata-rightthe two figures.
Options
Add any of these attributes to the same element.
data-left-name / data-right-name | Names for the two sides, shown as a legend. |
data-left-color / data-right-color | Bar colours. Default purple and pink. |
data-max | End of the scale on each side. Default the largest figure. |
data-prefix / data-suffix | Text around the figures, e.g. %. |
data-values | false hides the figures. |
data-duration | Time for each bar to grow. Default 800 ms. |
data-stagger | Gap between rows. Default 70 ms. |
data-loop | Pull the bars back in and grow them again. |
<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><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>