← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-dumbbell-chart to a container with a height, with one child per row: its text is the name, data-from and data-to the two values.

Options

Add any of these attributes to the same element.

data-before / data-afterNames for the two dots, shown as a legend.
data-from-color / data-to-colorDot colours. Default pale lilac and purple.
data-min / data-maxEnds of the scale. Default fitted to the data.
data-prefix / data-suffixText around the figures, e.g. %.
data-changefalse hides the change at the end of each row.
data-sortOrder the rows by the size of the change, biggest rise first.
data-durationTime for each bar to stretch. Default 800 ms.
data-staggerGap between rows. Default 110 ms.
data-loopShrink back and stretch again every few seconds.
1 · Snippet
<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>
2 · Your HTML
<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>