Dot Plot Slide
A dot plot whose dots slide along their lines to their values, easing to a stop as the figures beside them count up. Give each row two values and a bar stretches between the dots to show the gap, as in a before-and-after plot.
16–2425–3435–4445–5455+
How to use it
- Paste the snippet just before
</head>. - Add
data-dot-plot-slideto a container, with one child per row: its text is the name anddata-valuesits value, or one value per series.
Options
Add any of these attributes to the same element.
data-series | Comma-separated series names, shown as a legend. |
data-colors | Comma-separated colours, one per series. Default purple, pink, blue… |
data-min / data-max | Ends of the scale. Default 0 and a round number above the largest value. |
data-prefix / data-suffix | Text around the values, e.g. h. |
data-axis | false hides the scale underneath. |
data-duration | Time for each dot to slide. Default 1100 ms. |
data-stagger | Gap between rows. Default 110 ms. |
data-loop | Slide back and out again every few seconds. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Dot Plot Slide: a dot plot whose dots slide along their lines to their
// values, easing to a stop, while the figures beside them count up. Give
// each row two or more values and a bar stretches between its dots to
// show the gap, as in a before-and-after (dumbbell) plot.
// Usage:
// <div data-dot-plot-slide data-series="2019,2024" data-suffix="h">
// <span data-values="5.1,7.2">16–24</span>
// <span data-values="4.4,6.1">25–34</span>
// </div>
// Each child is one row: its text is the name, data-values its value or
// values (one per series, in order).
// Optional: data-series (series names, shown as a legend), data-colors
// ="#a855f7,#ec4899,…", data-min / data-max (ends of the scale, default 0
// and a round number above the largest value), data-prefix / data-suffix
// (around the values), data-axis="false" (no scale underneath),
// data-duration (ms for each dot to slide, default 1100), data-stagger
// (gap between rows, default 110), data-loop (slide back and again).
var DEFAULTS = { duration: 1100, stagger: 110, hold: 2600 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];
var css =
'.dpl{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr);gap:4px 12px;align-items:center;font-size:12px;font-variant-numeric:tabular-nums}' +
'.dpl-legend{grid-column:1/-1;display:flex;flex-wrap:wrap;justify-content:center;gap:4px 14px;font-weight:600;margin-bottom:6px;opacity:.85}' +
'.dpl-legend span{display:inline-flex;align-items:center;gap:6px}' +
'.dpl-legend i{width:10px;height:10px;border-radius:50%;background:var(--dpl-c)}' +
'.dpl-name{font-weight:600;text-align:right;white-space:nowrap;opacity:.8}' +
'.dpl-lane{position:relative;height:30px;margin:0 var(--dpl-r,34px) 0 var(--dpl-l,4px)}' +
'.dpl-line{position:absolute;left:0;right:0;top:50%;border-top:1px dashed color-mix(in srgb,currentColor 22%,transparent)}' +
'.dpl-gap{position:absolute;top:50%;height:6px;margin-top:-3px;border-radius:3px;background:color-mix(in srgb,currentColor 14%,transparent)}' +
'.dpl-dot{position:absolute;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:var(--dpl-c);' +
'box-shadow:0 0 0 2px #fff,0 2px 6px color-mix(in srgb,var(--dpl-c) 45%,transparent)}' +
'.dpl-val{position:absolute;top:50%;transform:translateY(-50%);font-weight:700;white-space:nowrap}' +
'.dpl-axis{position:relative;height:16px;margin:0 var(--dpl-r,34px) 0 var(--dpl-l,4px);border-top:1px solid color-mix(in srgb,currentColor 25%,transparent)}' +
'.dpl-axis span{position:absolute;top:3px;transform:translateX(-50%);font-size:11px;opacity:.55}';
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 { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function setup(el) {
var d = el.dataset;
var colors = d.colors ? d.colors.split(',') : PALETTE;
var series = d.series ? d.series.split(',').map(function (s) { return s.trim(); }) : [];
var pre = d.prefix || '', suf = d.suffix || '';
var rows = [];
Array.prototype.forEach.call(el.children, function (r) {
var vals = (r.getAttribute('data-values') || r.getAttribute('data-value') || '').split(/[\s,]+/).map(parseFloat).filter(function (x) { return !isNaN(x); });
if (vals.length) rows.push({ name: r.textContent.trim(), vals: vals });
});
var all = [];
rows.forEach(function (r) { all = all.concat(r.vals); });
var dec = all.some(function (v) { return v % 1; }) ? 1 : 0;
var min = d.min !== undefined ? +d.min : Math.min(0, Math.min.apply(null, all));
var hi = Math.max.apply(null, all);
var raw = (hi - min) / 4 || 1, p = Math.pow(10, Math.floor(Math.log10(raw)));
var step = [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
var max = d.max !== undefined ? +d.max : Math.ceil(hi / step) * step;
if (max <= min) max = min + 1;
function fmt(v) { return pre + v.toFixed(dec) + suf; }
var multi = rows.some(function (r) { return r.vals.length > 1; });
el.classList.add('dpl');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Dot plot. ' + rows.map(function (r) {
return r.name + ': ' + r.vals.map(function (v, i) { return (series[i] ? series[i] + ' ' : '') + fmt(v); }).join(', ');
}).join('. ') + '.');
if (multi) el.style.setProperty('--dpl-l', '38px');
var html = series.length ? '<div class="dpl-legend" aria-hidden="true">' + series.map(function (s, i) {
return '<span style="--dpl-c:' + esc(colors[i % colors.length]) + '"><i></i>' + esc(s) + '</span>';
}).join('') + '</div>' : '';
rows.forEach(function (r) {
html += '<div class="dpl-name" aria-hidden="true">' + esc(r.name) + '</div><div class="dpl-lane" aria-hidden="true"><span class="dpl-line"></span>' +
(r.vals.length > 1 ? '<span class="dpl-gap"></span>' : '') +
r.vals.map(function (v, i) { return '<span class="dpl-dot" style="--dpl-c:' + esc(colors[i % colors.length]) + '"></span><span class="dpl-val"></span>'; }).join('') + '</div>';
});
if (d.axis !== 'false') {
var ticks = '';
for (var t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) {
ticks += '<span style="left:' + ((t - min) / (max - min) * 100) + '%">' + esc(pre + (Math.round(t * 100) / 100) + suf) + '</span>';
}
html += '<div aria-hidden="true"></div><div class="dpl-axis" aria-hidden="true">' + ticks + '</div>';
}
el.innerHTML = html;
var lanes = el.querySelectorAll('.dpl-lane');
rows.forEach(function (r, i) {
r.dots = Array.prototype.slice.call(lanes[i].querySelectorAll('.dpl-dot'));
r.labels = Array.prototype.slice.call(lanes[i].querySelectorAll('.dpl-val'));
r.gap = lanes[i].querySelector('.dpl-gap');
});
var c = { el: el, rows: rows, min: min, max: max, fmt: fmt };
el._dpl = c;
return c;
}
// Row r at k (0–1): each dot k of the way from the start of the scale.
function set(c, r, k) {
var pos = r.vals.map(function (v) { return c.min + (v - c.min) * k; });
var lo = Math.min.apply(null, pos), hiV = Math.max.apply(null, pos);
var pct = function (v) { return (v - c.min) / (c.max - c.min) * 100; };
r.dots.forEach(function (dot, i) { dot.style.left = pct(pos[i]) + '%'; });
r.labels.forEach(function (lab, i) {
var left = r.vals.length > 1 && pos[i] === lo && lo !== hiV;
lab.textContent = c.fmt(pos[i]);
lab.style.left = left ? 'auto' : 'calc(' + pct(pos[i]) + '% + 11px)';
lab.style.right = left ? 'calc(' + (100 - pct(pos[i])) + '% + 11px)' : 'auto';
// Two dots in the same place share one figure.
lab.style.visibility = r.vals.length > 1 && lo === hiV && i > 0 ? 'hidden' : '';
});
if (r.gap) {
r.gap.style.left = pct(lo) + '%';
r.gap.style.width = (pct(hiV) - pct(lo)) + '%';
}
}
function play(c) {
var d = c.el.dataset;
var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var tl = anime.createTimeline();
c.rows.forEach(function (r, i) {
var st = { k: 0 };
set(c, r, 0);
tl.add(r.dots, { scale: [0, 1], duration: 300, ease: 'outBack(2)' }, i * stagger);
tl.add(r.labels, { opacity: [0, 1], duration: 300 }, i * stagger + 150);
tl.add(st, { k: 1, duration: dur, ease: 'outBack(1.15)', onUpdate: function () { set(c, r, st.k); } }, i * stagger + 200);
});
if (d.loop !== undefined && d.loop !== 'false') {
tl.then(function () {
setTimeout(function () {
var o = { k: 1 }, labels = [];
c.rows.forEach(function (r) { labels = labels.concat(r.labels); });
anime.animate(labels, { opacity: 0, duration: 300 });
anime.animate(o, {
k: 0, duration: 700, ease: 'inOut(3)',
onUpdate: function () { c.rows.forEach(function (r) { set(c, r, o.k); }); },
onComplete: function () { setTimeout(function () { play(c); }, 200); }
});
}, 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._dpl);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-dot-plot-slide]').forEach(function (el) {
if (el._dpl) return;
var c = setup(el);
c.rows.forEach(function (r) { set(c, r, reduced() ? 1 : 0); });
if (reduced()) return;
c.rows.forEach(function (r) { anime.utils.set(r.dots, { scale: 0 }); anime.utils.set(r.labels, { opacity: 0 }); });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-dot-plot-slide data-series="2019,2024" data-suffix="h">
<span data-values="5.1,7.2">16-24</span>
<span data-values="4.4,6.1">25-34</span>
<span data-values="3.8,5.0">35-44</span>
</div>