← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-dot-plot-slide to a container, with one child per row: its text is the name and data-values its value, or one value per series.

Options

Add any of these attributes to the same element.

data-seriesComma-separated series names, shown as a legend.
data-colorsComma-separated colours, one per series. Default purple, pink, blue…
data-min / data-maxEnds of the scale. Default 0 and a round number above the largest value.
data-prefix / data-suffixText around the values, e.g. h.
data-axisfalse hides the scale underneath.
data-durationTime for each dot to slide. Default 1100 ms.
data-staggerGap between rows. Default 110 ms.
data-loopSlide back and out again every few seconds.
1 · Snippet
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>
2 · Your HTML
<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>