Small Multiples
A grid of mini charts on one shared scale, drawn in one after another. Each card rises in, its line draws across with the area filling beneath, and its latest figure counts up as a dot lands on the end. Shared scales make the cards easy to compare at a glance.
LeedsYorkBathHullDerbyTruroElyWells
How to use it
- Paste the snippet just before
</head>. - Add
data-small-multiplesto a container, with one child per chart: its text is the title anddata-valuesits figures.
Options
Add any of these attributes to the same element.
data-columns | Fix the number of columns. Default as many as fit at about 120px each. |
data-shared | false gives each chart its own scale. |
data-color | Line colour, on the grid or one chart to pick it out. Default #a855f7. |
data-prefix / data-suffix | Text around the figures, e.g. £ and k. |
data-duration | Time for each chart to draw. Default 900 ms. |
data-stagger | Gap between charts. Default 140 ms. |
data-loop | Clear and draw again every few seconds. |
The cards are white with a faint border; restyle them with .smm-card. Give the grid a height, or set one on .smm-plot, for taller charts.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Small Multiples: a grid of mini charts on the same scale, drawn in one
// after another. Each card rises in, its line draws across with the area
// filling beneath, and its latest figure counts up as a dot lands on the
// end of the line.
// Usage:
// <div data-small-multiples>
// <span data-values="12,18,15,26,22,34">Paris</span>
// <span data-values="20,22,19,24,28,27">Rome</span>
// </div>
// Each child is one chart: its text is the title, data-values its figures.
// Optional: data-columns (fix the number of columns; default as many as
// fit at about 120px each), data-shared="false" (give each chart its own
// scale instead of a shared one), data-color (default #a855f7; also on one
// chart to pick it out), data-prefix / data-suffix (around the figures),
// data-duration (ms for each chart to draw, default 900), data-stagger
// (gap between charts, default 140), data-loop (clear and draw again).
var DEFAULTS = { duration: 900, stagger: 140, hold: 2600, color: '#a855f7' };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.smm{display:grid;gap:8px;font-size:11px;font-variant-numeric:tabular-nums}' +
'.smm-card{position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;background:#fff;border:1px solid rgba(0,0,0,.06);' +
'border-radius:10px;padding:7px 8px 6px;box-shadow:0 1px 3px rgba(20,10,40,.05)}' +
'.smm-head{display:flex;justify-content:space-between;align-items:baseline;gap:6px;white-space:nowrap}' +
'.smm-name{opacity:.65;overflow:hidden;text-overflow:ellipsis}' +
'.smm-val{font-weight:700;font-size:12px}' +
'.smm-plot{position:relative;flex:1;min-height:24px;margin-top:4px}' +
'.smm svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.smm-base{stroke:currentColor;stroke-opacity:.15}' +
'.smm-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}' +
'.smm-dot{stroke:#fff;stroke-width:1.5}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function mk(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
var uid = 0;
function setup(el) {
var d = el.dataset;
var items = [];
Array.prototype.forEach.call(el.children, function (ch) {
var vs = (ch.getAttribute('data-values') || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
if (vs.length < 2) return;
items.push({ name: ch.textContent.trim(), vs: vs, color: ch.getAttribute('data-color') || d.color || DEFAULTS.color });
});
var shared = d.shared !== 'false';
var all = [].concat.apply([], items.map(function (it) { return it.vs; }));
var gMin = Math.min(0, Math.min.apply(null, all)), gMax = Math.max.apply(null, all);
var dec = all.some(function (v) { return v % 1; }) ? 1 : 0;
var pre = d.prefix || '', suf = d.suffix || '';
var c = { el: el, items: items, k: items.map(function () { return 0; }) };
c.fmt = function (v) { return pre + v.toFixed(dec) + suf; };
el.classList.add('smm');
el.style.gridTemplateColumns = +d.columns ? 'repeat(' + (+d.columns) + ',minmax(0,1fr))' : 'repeat(auto-fit,minmax(min(120px,100%),1fr))';
el.innerHTML = '';
items.forEach(function (it) {
it.lo = shared ? gMin : Math.min(0, Math.min.apply(null, it.vs));
it.hi = shared ? gMax : Math.max.apply(null, it.vs);
var card = document.createElement('div');
card.className = 'smm-card';
card.setAttribute('role', 'img');
card.setAttribute('aria-label', it.name + ': ' + it.vs.map(c.fmt).join(', '));
card.innerHTML = '<div class="smm-head" aria-hidden="true"><span class="smm-name"></span><span class="smm-val"></span></div><div class="smm-plot"></div>';
card.querySelector('.smm-name').textContent = it.name;
it.val = card.querySelector('.smm-val');
it.plot = card.querySelector('.smm-plot');
it.card = card;
el.appendChild(card);
});
el._smm = c;
layout(c);
return c;
}
function layout(c) {
c.items.forEach(function (it) {
var W = it.plot.clientWidth, H = it.plot.clientHeight;
if (!W || !H) return;
it.W = W; it.H = H;
var pad = 4;
var x = function (i) { return i / (it.vs.length - 1) * W; };
var y = function (v) { return H - (v - it.lo) / ((it.hi - it.lo) || 1) * (H - pad); };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
var id = 'smm-g' + (++uid);
var lg = mk('linearGradient', { id: id, x1: 0, y1: 0, x2: 0, y2: 1 }, mk('defs', {}, svg));
mk('stop', { offset: 0, 'stop-color': it.color, 'stop-opacity': .3 }, lg);
mk('stop', { offset: 1, 'stop-color': it.color, 'stop-opacity': 0 }, lg);
var pts = it.vs.map(function (v, i) { return x(i).toFixed(1) + ',' + y(v).toFixed(1); });
mk('line', { class: 'smm-base', x1: 0, x2: W, y1: H - .5, y2: H - .5 }, svg);
it.area = mk('path', { fill: 'url(#' + id + ')', d: 'M0,' + H + 'L' + pts.join('L') + 'L' + W + ',' + H + 'Z' }, svg);
it.line = mk('path', { class: 'smm-line', stroke: it.color, d: 'M' + pts.join('L') }, svg);
it.dot = mk('circle', { class: 'smm-dot', cx: W, cy: y(it.vs[it.vs.length - 1]), fill: it.color, r: 0 }, svg);
it.plot.innerHTML = '';
it.plot.appendChild(svg);
it.len = it.line.getTotalLength();
it.line.style.strokeDasharray = it.len + ' ' + (it.len + 1);
});
render(c);
}
// Chart i at k (0–1): the card rises in, then the line draws and the dot lands.
function render(c) {
c.items.forEach(function (it, i) {
var k = c.k[i];
var rise = clamp01(k / 0.3), e = 1 - Math.pow(1 - rise, 3);
it.card.style.opacity = e;
it.card.style.transform = 'translateY(' + ((1 - e) * 10).toFixed(1) + 'px)';
var draw = clamp01((k - 0.15) / 0.75), de = draw < .5 ? 2 * draw * draw : 1 - Math.pow(-2 * draw + 2, 2) / 2;
if (it.line) {
it.line.style.strokeDashoffset = it.len * (1 - de);
it.area.style.opacity = de;
var p = clamp01((k - 0.85) / 0.15);
it.dot.setAttribute('r', (3.2 * (p < 1 ? p * (1.3 - 0.3 * p) : 1)).toFixed(2));
}
var last = it.vs[it.vs.length - 1];
it.val.textContent = c.fmt(last * de);
});
}
function play(c) {
var d = c.el.dataset;
var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
c.k = c.items.map(function () { return 0; });
render(c);
var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
c.items.forEach(function (it, i) {
var s = { k: 0 };
tl.add(s, { k: 1, duration: dur, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, i * stagger);
});
if (d.loop !== undefined && d.loop !== 'false') {
tl.then(function () {
setTimeout(function () {
var o = { k: 1 };
anime.animate(o, {
k: 0, duration: 500, 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._smm);
});
}, { threshold: 0.25 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._smm;
if (!c) return;
var moved = c.items.some(function (it) { return it.plot.clientWidth !== it.W || it.plot.clientHeight !== it.H; });
if (moved) layout(c);
});
});
document.querySelectorAll('[data-small-multiples]').forEach(function (el) {
if (el._smm) return;
var c = setup(el);
ro.observe(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-small-multiples data-prefix="£" data-suffix="k">
<span data-values="12,15,14,18,22,21,26,30">Leeds</span>
<span data-values="8,9,12,11,10,13,12,14">York</span>
<span data-values="20,18,16,15,17,14,12,11">Bath</span>
</div>