← Charts

Slope Graph

A before-and-after slope graph. Each line draws across flat at its before value, then tilts to its after value with its figure counting as it goes, so you see at once what rose and what fell. Point at a line to pick it out.

SearchSocialAdsDirectEmailReferral

How to use it

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

Options

Add any of these attributes to the same element.

data-before / data-afterTitles over the two columns, e.g. 2023 and 2024.
data-up / data-downLine colours for a rise and a fall. Default green and red.
data-colorOn a line: its colour.
data-prefix / data-suffixText around the values, e.g. %.
data-changefalse hides the percentage change after each right-hand figure.
data-durationTime for each line to tilt. Default 900 ms.
data-staggerGap between lines. Default 120 ms.
data-loopLevel the lines and tilt again.

Labels that would overlap are nudged apart.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Slope Graph: a before-and-after comparison. Each line appears flat at
    // its before value, then tilts to its after value, its right-hand figure
    // counting as it goes, so rises and falls read at a glance.
    // Usage:
    //   <div data-slope-graph data-before="2023" data-after="2024" style="height:260px">
    //     <span data-from="42" data-to="61">Search</span>
    //     <span data-from="35" data-to="22">Social</span>
    //   </div>
    // Each child is one line: its text is the name, data-from and data-to
    // the values. Labels that would overlap are nudged apart.
    // Optional: data-before / data-after (column titles), data-up / data-down
    // (line colours for a rise and a fall, defaults green and red), data-color
    // on a line, data-prefix / data-suffix (around the values), data-change
    // ="false" (hide the change after each right-hand figure), data-duration
    // (ms for each line to tilt, default 900), data-stagger (gap between
    // lines, default 120), data-loop (level the lines and tilt again).
    var DEFAULTS = { duration: 900, stagger: 120, hold: 2600, up: '#10b981', down: '#f43f5e' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.slg{position:relative;font-size:12px;font-variant-numeric:tabular-nums}' +
      '.slg svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.slg-axis{stroke:currentColor;stroke-opacity:.25}' +
      '.slg-head{fill:currentColor;fill-opacity:.6;font-weight:700;font-size:11px;letter-spacing:.04em}' +
      '.slg-line{stroke-width:2.5;stroke-linecap:round;fill:none}' +
      '.slg-dot{stroke:#fff;stroke-width:2}' +
      '.slg-name{fill:currentColor;fill-opacity:.65}' +
      '.slg-val{fill:currentColor;font-weight:700}' +
      '.slg-chg{font-weight:600;font-size:11px}' +
      '.slg-row{transition:opacity .25s}' +
      '.slg svg:hover .slg-row{opacity:.25}' +
      '.slg svg .slg-row:hover{opacity:1}';
    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; }

    // Push label positions apart so none sit closer than gap, keeping order.
    function dodge(ys, gap, lo, hi) {
      var idx = ys.map(function (y, i) { return i; }).sort(function (a, b) { return ys[a] - ys[b]; });
      var out = ys.slice();
      for (var pass = 0; pass < 20; pass++) {
        var moved = false;
        for (var j = 1; j < idx.length; j++) {
          var a = idx[j - 1], b = idx[j], over = gap - (out[b] - out[a]);
          if (over > 0.01) { out[a] -= over / 2; out[b] += over / 2; moved = true; }
        }
        idx.forEach(function (i) { out[i] = Math.max(lo, Math.min(hi, out[i])); });
        if (!moved) break;
      }
      return out;
    }

    function setup(el) {
      var d = el.dataset;
      var pre = d.prefix || '', suf = d.suffix || '';
      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, color: r.getAttribute('data-color') });
      });
      var dec = rows.some(function (r) { return r.from % 1 || r.to % 1; }) ? 1 : 0;
      function fmt(v) { return pre + v.toFixed(dec) + suf; }
      var c = { el: el, rows: rows, fmt: fmt, dec: dec, k: rows.map(function () { return 0; }), draw: 0 };
      el.classList.add('slg');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Slope graph' + (d.before && d.after ? ' from ' + d.before + ' to ' + d.after : '') + '. ' +
        rows.map(function (r) { return r.name + ': ' + fmt(r.from) + ' to ' + fmt(r.to); }).join('. ') + '.');
      var all = [];
      rows.forEach(function (r) { all.push(r.from, r.to); });
      c.lo = Math.min.apply(null, all); c.hi = Math.max.apply(null, all);
      if (c.hi === c.lo) c.hi = c.lo + 1;
      el._slg = c;
      layout(c);
      return c;
    }

    function textW(s, size) { return String(s).length * size * 0.56; }

    function layout(c) {
      var W = c.el.clientWidth, H = c.el.clientHeight, d = c.el.dataset;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var showChg = d.change !== 'false';
      var heads = d.before || d.after;
      var top = heads ? 34 : 10, bot = H - 10;
      var nameW = Math.min(W * 0.3, Math.max.apply(null, c.rows.map(function (r) { return textW(r.name, 12); })));
      var valW = Math.max.apply(null, c.rows.map(function (r) { return Math.max(textW(c.fmt(r.from), 12.5), textW(c.fmt(r.to), 12.5)); }));
      var chgW = showChg ? 42 : 0;
      var x0 = nameW + valW + 18, x1 = W - valW - chgW - 12;
      if (x1 - x0 < 60) { showChg = false; x1 = W - valW - 12; }
      c.x0 = x0; c.x1 = x1;
      var y = function (v) { return bot - (v - c.lo) / (c.hi - c.lo) * (bot - top); };
      c.y = y;
      c.leftY = dodge(c.rows.map(function (r) { return y(r.from); }), 15, top, bot);
      c.rightY = dodge(c.rows.map(function (r) { return y(r.to); }), 15, top, bot);
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      mk('line', { class: 'slg-axis', x1: x0, x2: x0, y1: top - 6, y2: bot + 4 }, svg);
      mk('line', { class: 'slg-axis', x1: x1, x2: x1, y1: top - 6, y2: bot + 4 }, svg);
      if (d.before) mk('text', { class: 'slg-head', x: x0, y: 12, 'text-anchor': 'middle' }, svg).textContent = d.before;
      if (d.after) mk('text', { class: 'slg-head', x: x1, y: 12, 'text-anchor': 'middle' }, svg).textContent = d.after;
      c.parts = c.rows.map(function (r, i) {
        var col = r.color || (r.to >= r.from ? (d.up || DEFAULTS.up) : (d.down || DEFAULTS.down));
        var g = mk('g', { class: 'slg-row' }, svg);
        var p = { g: g };
        p.line = mk('line', { class: 'slg-line', x1: x0, y1: y(r.from), stroke: col }, g);
        p.d0 = mk('circle', { class: 'slg-dot', cx: x0, cy: y(r.from), r: 4.5, fill: col }, g);
        p.d1 = mk('circle', { class: 'slg-dot', r: 4.5, fill: col }, g);
        var lt = mk('text', { x: x0 - 10, y: c.leftY[i], dy: '.35em', 'text-anchor': 'end' }, g);
        var nm = mk('tspan', { class: 'slg-name' }, lt);
        nm.textContent = r.name + ' ';
        mk('tspan', { class: 'slg-val' }, lt).textContent = c.fmt(r.from);
        p.rt = mk('text', { x: x1 + 10, dy: '.35em' }, g);
        p.rv = mk('tspan', { class: 'slg-val' }, p.rt);
        if (showChg) {
          p.rc = mk('tspan', { class: 'slg-chg', fill: col }, p.rt);
        }
        p.r = r;
        return p;
      });
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      render(c);
    }

    function render(c) {
      if (!c.parts) return;
      var draw = c.draw;
      c.parts.forEach(function (p, i) {
        var r = p.r, k = c.k[i];
        var v = r.from + (r.to - r.from) * k;
        var reach = clamp01(draw * 1.4 - i * 0.4 / c.parts.length);
        var ex = c.x0 + (c.x1 - c.x0) * reach;
        var ey = c.y(v);
        p.line.setAttribute('x2', ex);
        p.line.setAttribute('y2', ey);
        p.d0.style.opacity = reach > 0 ? 1 : 0;
        p.d1.setAttribute('cx', ex);
        p.d1.setAttribute('cy', ey);
        p.d1.style.opacity = reach >= 1 ? 1 : 0;
        p.rt.setAttribute('y', c.leftY[i] + (c.rightY[i] - c.leftY[i]) * Math.min(1, k));
        p.rt.style.opacity = clamp01((reach - 0.8) * 5);
        p.rv.textContent = c.fmt(v);
        if (p.rc) {
          var ch = r.from ? (v - r.from) / Math.abs(r.from) * 100 : 0;
          p.rc.textContent = k > 0.02 ? '  ' + (ch >= 0 ? '+' : '−') + Math.abs(Math.round(ch)) + '%' : '';
        }
      });
    }

    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.k.map(function () { return 0; });
      c.draw = 0;
      render(c);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      tl.add(c, { draw: 1, duration: 700, ease: 'out(2)' }, 0);
      c.rows.forEach(function (r, i) {
        var s = { k: 0 };
        tl.add(s, { k: 1, duration: dur, ease: 'outBack(1.2)', onUpdate: function () { c.k[i] = s.k; } }, 750 + i * stagger);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 700, ease: 'inOut(2)',
              onUpdate: function () { c.k = c.k.map(function () { return o.k; }); c.draw = o.k < 0.5 ? o.k * 2 : 1; render(c); },
              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._slg);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._slg;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-slope-graph]').forEach(function (el) {
        if (el._slg) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { c.k = c.k.map(function () { return 1; }); c.draw = 1; render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-slope-graph data-before="2023" data-after="2024" data-suffix="%" style="height: 260px">
  <span data-from="38" data-to="52">Search</span>
  <span data-from="31" data-to="19">Social</span>
  <span data-from="14" data-to="21">Email</span>
</div>