← Charts

Line Chart Race

Several lines race along through time, side by side. A dot and a label ride the tip of each one, showing its name and its figure as it changes, and the current month sits large behind the chart. Watch the lead change hands.

RoversUnitedCityAthleticWanderers

How to use it

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

Options

Add any of these attributes to the same element.

data-labelsComma-separated period names, shown along the bottom and large behind the chart.
data-colorsComma-separated colours, one per line. Default purple, pink, blue…
data-colorOn a line: its colour.
data-prefix / data-suffixText around the figures, e.g. pts.
data-durationTime for the whole race. Default 5000 ms.
data-loopFade out and race again.

Labels that would overlap are nudged apart, so close lines stay readable.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Line Chart Race: several lines race along through time, each with a dot
    // and a label riding its tip that shows the name and current value. The
    // axes stretch as they go, so the tips race along the right-hand edge, and
    // the current period is shown large behind the chart.
    // Usage:
    //   <div data-line-chart-race data-labels="2019,2020,2021,2022" style="height:260px">
    //     <span data-values="12,18,30,41">Paris</span>
    //     <span data-values="20,22,26,33">Rome</span>
    //   </div>
    // Each child is one line: its text is the name, data-values one value per
    // period. Tip labels that would overlap are nudged apart.
    // Optional: data-labels (one per period, shown under the chart and as the
    // big period marker), data-colors (comma-separated, one per line, default
    // purple, pink, blue…), data-color on a line, data-prefix / data-suffix
    // (around the values), data-duration (ms for the whole race, default 5000),
    // data-loop (fade out and race again).
    var DEFAULTS = { duration: 5000, hold: 2200 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.lcr{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.lcr svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.lcr-grid{stroke:currentColor;stroke-opacity:.08}' +
      '.lcr-axis{stroke:currentColor;stroke-opacity:.3}' +
      '.lcr-tick{fill:currentColor;fill-opacity:.5}' +
      '.lcr-period{fill:currentColor;fill-opacity:.1;font-weight:800;text-anchor:end;letter-spacing:-.03em}' +
      '.lcr-line{fill:none;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}' +
      '.lcr-dot{stroke:#fff;stroke-width:2}' +
      '.lcr-name{font-weight:700}' +
      '.lcr-val{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 nice(hi) {
      var raw = hi / 4, p = Math.pow(10, Math.floor(Math.log10(raw)));
      return [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
    }
    // 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 colors = d.colors ? d.colors.split(',') : PALETTE;
      var lines = [];
      Array.prototype.forEach.call(el.children, function (ch) {
        var vs = (ch.getAttribute('data-values') || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
        if (vs.length < 2) return;
        lines.push({ name: ch.textContent.trim(), vs: vs, color: ch.getAttribute('data-color') || colors[lines.length % colors.length] });
      });
      var n = Math.max.apply(null, lines.map(function (l) { return l.vs.length; }));
      var labels = d.labels ? d.labels.split(',') : [];
      var hi = Math.max.apply(null, lines.map(function (l) { return Math.max.apply(null, l.vs); })) || 1;
      var step = nice(hi);
      var dec = lines.some(function (l) { return l.vs.some(function (v) { return v % 1; }); }) ? 1 : 0;
      var pre = d.prefix || '', suf = d.suffix || '';
      var c = { el: el, lines: lines, n: n, labels: labels, step: step, max: Math.ceil(hi * 1.05 / step) * step, t: 0, fade: 1 };
      c.fmt = function (v) { return pre + v.toFixed(dec) + suf; };
      el.classList.add('lcr');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Line chart. ' + lines.map(function (l) {
        return l.name + ' from ' + c.fmt(l.vs[0]) + ' to ' + c.fmt(l.vs[l.vs.length - 1]);
      }).join(', ') + '.');
      el._lcr = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var W = c.el.clientWidth, H = c.el.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var longest = Math.max.apply(null, c.lines.map(function (l) { return l.name.length + c.fmt(Math.max.apply(null, l.vs)).length + 1; }));
      var hiAll = Math.max.apply(null, c.lines.map(function (l) { return Math.max.apply(null, l.vs); }));
      c.left = String(Math.round(hiAll * 1.3)).length * 6.4 + 8;
      c.right = W - Math.min(W * 0.4, longest * 6.3 + 16);
      c.top = 10;
      c.base = H - (c.labels.length ? 20 : 6);
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      c.gGrid = mk('g', {}, svg);
      mk('line', { class: 'lcr-axis', x1: c.left, x2: c.right, y1: c.base + .5, y2: c.base + .5 }, svg);
      c.gX = mk('g', {}, svg);
      if (c.labels.length) {
        c.period = mk('text', { class: 'lcr-period', x: c.right - 4, y: c.base - 8, 'font-size': Math.round(Math.min(H * 0.28, W * 0.13)) }, svg);
      }
      c.lines.forEach(function (l) {
        l.path = mk('path', { class: 'lcr-line', stroke: l.color }, svg);
        l.dot = mk('circle', { class: 'lcr-dot', r: 4.5, fill: l.color }, svg);
        l.tag = mk('text', { dy: '.35em' }, svg);
        l.nm = mk('tspan', { class: 'lcr-name', fill: l.color }, l.tag);
        l.vl = mk('tspan', { class: 'lcr-val', dx: 4 }, l.tag);
        l.nm.textContent = l.name;
      });
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      c.svg = svg;
      c.ym = 0;
      render(c);
    }

    function valueAt(l, t) {
      var i = Math.min(Math.floor(t), l.vs.length - 1), f = t - i;
      var a = l.vs[i], b = l.vs[Math.min(i + 1, l.vs.length - 1)];
      // A smoothstep between periods keeps the tip from moving in jerky bursts.
      return a + (b - a) * (f * f * (3 - 2 * f));
    }

    // The x axis stretches to the current period and the y axis to the leader,
    // so the lines always fill the chart and the tips race along its right edge.
    function render(c) {
      if (!c.svg) return;
      var t = c.t, span = Math.max(t, 1), ys = [], hi = 0;
      var x = function (i) { return c.left + i / span * (c.right - c.left); };
      c.lines.forEach(function (l) {
        var tt = Math.min(t, l.vs.length - 1);
        for (var i = 0; i <= Math.floor(tt); i++) hi = Math.max(hi, l.vs[i]);
        l.v = valueAt(l, tt);
        hi = Math.max(hi, l.v);
      });
      var goal = Math.max(hi * 1.15, 1);
      c.ym = c.ym && !c.snap ? c.ym + (goal - c.ym) * 0.12 : goal;
      var y = function (v) { return c.base - v / c.ym * (c.base - c.top); };
      // Gridlines.
      var step = nice(c.ym), g = '';
      for (var v = step; v < c.ym; v += step) {
        var yy = y(v).toFixed(1);
        g += '<line class="lcr-grid" x1="' + c.left + '" x2="' + c.right + '" y1="' + yy + '" y2="' + yy + '"/>' +
          '<text class="lcr-tick" x="' + (c.left - 6) + '" y="' + yy + '" dy=".35em" text-anchor="end">' + (+v.toFixed(2)) + '</text>';
      }
      c.gGrid.innerHTML = g;
      // Period labels along the bottom, thinned out as they bunch up.
      if (c.labels.length) {
        var gap = (c.right - c.left) / span;
        var longest = Math.max.apply(null, c.labels.map(function (s) { return s.length; }));
        var every = Math.max(1, Math.ceil((longest * 6.4 + 8) / gap));
        var xs = '';
        for (var i = 0; i <= Math.floor(t + 1e-6) && i < c.labels.length; i++) {
          if (i % every) continue;
          xs += '<text class="lcr-tick" x="' + x(i).toFixed(1) + '" y="' + (c.H - 4) + '" text-anchor="middle">' + c.labels[i] + '</text>';
        }
        c.gX.innerHTML = xs;
      }
      c.lines.forEach(function (l) {
        var tt = Math.min(t, l.vs.length - 1), pts = [];
        for (var i = 0; i <= Math.floor(tt); i++) pts.push(x(i).toFixed(1) + ',' + y(l.vs[i]).toFixed(1));
        l.tx = x(tt); l.ty = y(l.v);
        pts.push(l.tx.toFixed(1) + ',' + l.ty.toFixed(1));
        l.path.setAttribute('d', 'M' + pts.join('L'));
        l.dot.setAttribute('cx', l.tx.toFixed(1));
        l.dot.setAttribute('cy', l.ty.toFixed(1));
        ys.push(l.ty);
      });
      var lab = dodge(ys, 14, c.top, c.base - 4);
      c.lines.forEach(function (l, i) {
        l.tag.setAttribute('x', (l.tx + 9).toFixed(1));
        l.tag.setAttribute('y', lab[i].toFixed(1));
        l.vl.textContent = c.fmt(l.v);
      });
      if (c.period) c.period.textContent = c.labels[Math.min(c.labels.length - 1, Math.round(t))] || '';
      c.svg.style.opacity = c.fade;
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      var s = { t: 0 };
      c.t = 0; c.fade = 1; c.ym = 0;
      render(c);
      anime.animate(s, {
        t: c.n - 1, duration: dur, ease: 'linear',
        onUpdate: function () { c.t = s.t; render(c); },
        onComplete: function () {
          if (d.loop === undefined || d.loop === 'false') return;
          setTimeout(function () {
            var o = { f: 1 };
            anime.animate(o, {
              f: 0, duration: 450, ease: 'in(2)',
              onUpdate: function () { c.fade = o.f; 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._lcr);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._lcr;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-line-chart-race]').forEach(function (el) {
        if (el._lcr) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { c.t = c.n - 1; c.snap = true; render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-line-chart-race data-labels="Jan,Feb,Mar,Apr,May,Jun"
     style="height: 260px">
  <span data-values="3,6,6,9,12,13">Rovers</span>
  <span data-values="1,4,7,10,10,13">United</span>
  <span data-values="3,3,4,7,10,14">City</span>
</div>