Dumbbell Chart
Each row has a pale dot for before and a strong dot for after, joined by a bar. Row by row, the before dot pops in, then the bar stretches out to the after value with its dot riding on the end, and the change counts up beside it in green or red.
Sign upCheckoutSearchHelpProfileSettings
How to use it
- Paste the snippet just before
</head>. - Add
data-dumbbell-chartto a container with a height, with one child per row: its text is the name,data-fromanddata-tothe two values.
Options
Add any of these attributes to the same element.
data-before / data-after | Names for the two dots, shown as a legend. |
data-from-color / data-to-color | Dot colours. Default pale lilac and purple. |
data-min / data-max | Ends of the scale. Default fitted to the data. |
data-prefix / data-suffix | Text around the figures, e.g. %. |
data-change | false hides the change at the end of each row. |
data-sort | Order the rows by the size of the change, biggest rise first. |
data-duration | Time for each bar to stretch. Default 800 ms. |
data-stagger | Gap between rows. Default 110 ms. |
data-loop | Shrink back and stretch again every few seconds. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Dumbbell Chart: one row per item, with a dot for before and a dot for
// after joined by a bar. Each before dot pops in, then the bar stretches
// out to the after value with its dot riding on the end, and the change
// counts up beside it, row by row.
// Usage:
// <div data-dumbbell-chart data-before="2015" data-after="2025" style="height:240px">
// <span data-from="42" data-to="61">Search</span>
// <span data-from="35" data-to="22">Social</span>
// </div>
// Each child is one row: its text is the name, data-from and data-to the
// two values.
// Optional: data-before / data-after (legend names), data-from-color /
// data-to-color (dot colours, defaults grey-lilac and purple), data-min /
// data-max (ends of the scale, default fitted to the data), data-prefix /
// data-suffix (around the figures), data-change="false" (hide the change
// at the end of each row), data-sort (order rows by the size of the gap),
// data-duration (ms for each bar to stretch, default 800), data-stagger
// (gap between rows, default 110), data-loop (shrink back and stretch again).
var DEFAULTS = { duration: 800, stagger: 110, hold: 2800, fromColor: '#c4b5fd', toColor: '#7c3aed' };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.dmb{position:relative;font-size:12px;font-variant-numeric:tabular-nums}' +
'.dmb svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.dmb-grid{stroke:currentColor;stroke-opacity:.09}' +
'.dmb-tick{fill:currentColor;fill-opacity:.45;text-anchor:middle}' +
'.dmb-name{fill:currentColor;fill-opacity:.75;text-anchor:end}' +
'.dmb-track{stroke:currentColor;stroke-opacity:.06;stroke-linecap:round}' +
'.dmb-dot{stroke:#fff;stroke-width:2}' +
'.dmb-chg{font-weight:700}' +
'.dmb-key{fill:currentColor;fill-opacity:.7}';
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; }
function outBack(t) { var s = 1.2; t -= 1; return t * t * ((s + 1) * t + s) + 1; }
function nice(span) {
var raw = span / 4, p = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10)), f = raw / p;
return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p;
}
function setup(el) {
var d = el.dataset;
var rows = [];
Array.prototype.forEach.call(el.children, function (r) {
var a = parseFloat(r.getAttribute('data-from')), b = parseFloat(r.getAttribute('data-to'));
if (isNaN(a) || isNaN(b)) return;
rows.push({ name: r.textContent.trim(), from: a, to: b });
});
if (!rows.length) return null;
if (d.sort !== undefined && d.sort !== 'false') rows.sort(function (p, q) { return (q.to - q.from) - (p.to - p.from); });
var pre = d.prefix || '', suf = d.suffix || '';
var dec = rows.some(function (r) { return r.from % 1 || r.to % 1; }) ? 1 : 0;
var all = [];
rows.forEach(function (r) { all.push(r.from, r.to); });
var lo = Math.min.apply(null, all), hi = Math.max.apply(null, all);
var step = nice((hi - lo) || 1);
var c = {
el: el, rows: rows, k: rows.map(function () { return 0; }), pop: rows.map(function () { return 0; }),
fmt: function (v) { return pre + v.toFixed(dec) + suf; },
chg: function (v) { return (v >= 0 ? '+' : '−') + pre + Math.abs(v).toFixed(dec) + suf; },
lo: d.min !== undefined ? +d.min : Math.floor(lo / step) * step,
hi: d.max !== undefined ? +d.max : Math.ceil(hi / step) * step,
step: step,
c0: d.fromColor || DEFAULTS.fromColor, c1: d.toColor || DEFAULTS.toColor
};
if (c.hi === c.lo) c.hi = c.lo + step;
el.classList.add('dmb');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Dumbbell chart' + (d.before && d.after ? ', ' + d.before + ' to ' + d.after : '') + '. ' +
rows.map(function (r) { return r.name + ': ' + c.fmt(r.from) + ' to ' + c.fmt(r.to); }).join('. ') + '.');
el._dmb = c;
layout(c);
return c;
}
function layout(c) {
var el = c.el, d = el.dataset, W = el.clientWidth, H = el.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var fs = W < 360 ? 11 : 12;
var legend = d.before || d.after;
var nameW = Math.min(W * 0.32, Math.max.apply(null, c.rows.map(function (r) { return r.name.length; })) * fs * 0.56) + 10;
c.showChg = d.change !== 'false' && W >= 240;
var chgW = c.showChg ? Math.max.apply(null, c.rows.map(function (r) { return c.chg(r.to - r.from).length; })) * fs * 0.6 + 10 : 8;
var top = legend ? 26 : 6, bot = H - 20;
var x0 = nameW + 8, x1 = W - chgW - 4;
c.x = function (v) { return x0 + (v - c.lo) / (c.hi - c.lo) * (x1 - x0); };
var rowH = (bot - top) / c.rows.length;
var dotR = Math.max(4, Math.min(7.5, rowH * 0.22));
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
var tickEvery = (x1 - x0) / ((c.hi - c.lo) / c.step) < 34 ? 2 : 1;
for (var v = c.lo, t = 0; v <= c.hi + 1e-9; v += c.step, t++) {
var gx = c.x(v);
mk('line', { class: 'dmb-grid', x1: gx, x2: gx, y1: top - 2, y2: bot }, svg);
if (t % tickEvery === 0) mk('text', { class: 'dmb-tick', x: gx, y: H - 5, 'font-size': fs - 1.5 }, svg).textContent = c.fmt(+v.toFixed(6)).replace(/\.0(?=\D*$)/, '');
}
if (legend) {
var lx = x0;
[[d.before, c.c0], [d.after, c.c1]].forEach(function (p) {
if (!p[0]) return;
mk('circle', { cx: lx + 5, cy: 9, r: 5, fill: p[1] }, svg);
var tx = mk('text', { class: 'dmb-key', x: lx + 14, y: 9, dy: '.35em', 'font-size': fs - 1 }, svg);
tx.textContent = p[0];
lx += 14 + p[0].length * fs * 0.6 + 16;
});
}
c.parts = c.rows.map(function (r, i) {
var y = top + rowH * (i + 0.5);
mk('text', { class: 'dmb-name', x: nameW, y: y, dy: '.35em', 'font-size': fs }, svg).textContent = r.name;
mk('line', { class: 'dmb-track', x1: x0, x2: x1, y1: y, y2: y, 'stroke-width': dotR * 2 }, svg);
var p = { r: r, y: y };
p.bar = mk('line', { y1: y, y2: y, x1: c.x(r.from), 'stroke-width': dotR * 0.9, 'stroke-linecap': 'round', stroke: c.c1, 'stroke-opacity': 0.45 }, svg);
p.d0 = mk('circle', { class: 'dmb-dot', cx: c.x(r.from), cy: y, r: dotR, fill: c.c0 }, svg);
p.d1 = mk('circle', { class: 'dmb-dot', cy: y, r: dotR, fill: c.c1 }, svg);
if (c.showChg) p.chg = mk('text', { class: 'dmb-chg', x: x1 + 10, y: y, dy: '.35em', 'font-size': fs, fill: r.to >= r.from ? '#059669' : '#e11d48' }, svg);
return p;
});
el.innerHTML = '';
el.appendChild(svg);
render(c);
}
function render(c) {
if (!c.parts) return;
c.parts.forEach(function (p, i) {
var r = p.r, k = c.k[i], pop = c.pop[i];
var v = r.from + (r.to - r.from) * k;
var ex = c.x(v);
p.d0.setAttribute('transform', 'translate(' + c.x(r.from) + ',' + p.y + ') scale(' + pop + ') translate(' + -c.x(r.from) + ',' + -p.y + ')');
p.bar.setAttribute('x2', ex);
p.bar.style.opacity = k > 0.01 ? 1 : 0;
p.d1.setAttribute('cx', ex);
p.d1.style.opacity = k > 0.01 ? 1 : 0;
if (p.chg) {
p.chg.textContent = c.chg(v - r.from);
p.chg.style.opacity = clamp01(k * 1.5);
}
});
}
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, span = 320 + dur + stagger * (n - 1);
var o = { t: 0 };
anime.animate(o, {
t: span, duration: span, ease: 'linear',
onUpdate: function () {
for (var i = 0; i < n; i++) {
var lt = o.t - i * stagger;
c.pop[i] = outBack(clamp01(lt / 300));
var q = clamp01((lt - 220) / dur);
c.k[i] = q >= 1 ? 1 : 1 - Math.pow(1 - q, 3);
}
render(c);
},
onComplete: function () {
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
var b = { k: 1 };
anime.animate(b, {
k: 0, duration: 600, ease: 'inOut(2)',
onUpdate: function () { c.k = c.k.map(function () { return b.k; }); c.pop = c.pop.map(function () { return Math.min(1, b.k * 2); }); render(c); },
onComplete: function () { if (c.visible) setTimeout(function () { play(c); }, 250); else c.waiting = true; }
});
}, DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._dmb;
c.visible = e.isIntersecting;
if (!c.visible) return;
if (!c.seen) { c.seen = true; play(c); }
else if (c.waiting) { c.waiting = false; play(c); }
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._dmb;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-dumbbell-chart]').forEach(function (el) {
if (el._dmb) return;
var c = setup(el);
if (!c) return;
ro.observe(el);
if (reduced()) { c.k = c.k.map(function () { return 1; }); c.pop = c.pop.map(function () { return 1; }); render(c); return; }
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-dumbbell-chart data-before="Before" data-after="After"
data-suffix="%" style="height: 240px">
<span data-from="62" data-to="88">Sign up</span>
<span data-from="55" data-to="79">Checkout</span>
<span data-from="74" data-to="69">Settings</span>
</div>