← Charts

Data Set Switch

Tabs above a chart switch between data sets. The bars morph to the new figures one after another with a little overshoot, the scale and its gridlines glide to fit, the colour blends across and the figures count. Set it to a line chart and the line bends to its new shape instead.

202320242025

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-data-set-switch and data-labels to a container with a height, with one child per data set: its text is the tab name and data-values its figures.

Options

Add any of these attributes to the same element.

data-typebars or line. Default bars.
data-colorsComma-separated colours, one per data set. Default purple, pink, blue…
data-colorOn a data set: its colour.
data-prefix / data-suffixText around the figures, e.g. $ and k.
data-valuesOn the chart: false hides the figures. They hide themselves when bars get thin.
data-durationTime for each bar to morph. Default 700 ms.
data-staggerGap between bars. Default 45 ms.
data-autoplaySwitch data sets by itself until someone picks one.

The tabs are buttons with aria-pressed, and the chart’s label is rewritten for screen readers on each switch. From a script: animacious.dataSetSwitch(el, 1).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Data Set Switch: a chart with a tab for each data set. Picking one
    // morphs the bars (or the line) smoothly to the new figures, one after
    // another, while the scale and the colour glide to match.
    // Usage:
    //   <div data-data-set-switch data-labels="Q1,Q2,Q3,Q4" style="height:260px">
    //     <span data-values="12,18,15,22">2023</span>
    //     <span data-values="20,24,31,28">2024</span>
    //   </div>
    // Each child is one data set: its text is the tab name, data-values its
    // figures, one per label.
    // Optional: data-type ("bars" or "line", default bars), data-colors
    // (comma-separated, one per set, default purple, pink, blue…), data-color
    // on a set, data-prefix / data-suffix (around the figures), data-values
    // ="false" on the chart (hide the figures), data-duration (ms for each
    // bar to morph, default 700), data-stagger (gap between bars, default
    // 45), data-autoplay (switch sets by itself until someone picks one).
    // Script use: animacious.dataSetSwitch(el, index) switches to a set.
    var DEFAULTS = { duration: 700, stagger: 45, interval: 2600, idle: 6000 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.dss{display:flex;flex-direction:column;gap:10px;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.dss-tabs{display:flex;justify-content:center;gap:4px;flex:none;flex-wrap:wrap}' +
      '.dss-tab{all:unset;box-sizing:border-box;position:relative;padding:4px 12px;border-radius:99px;font:inherit;font-size:12px;font-weight:600;color:inherit;opacity:.6;cursor:pointer;transition:opacity .25s,color .25s}' +
      '.dss-tab:focus-visible{outline:2px solid currentColor;outline-offset:1px}' +
      '.dss-tab.dss-act{opacity:1;color:#fff}' +
      '.dss-tab span{position:relative}' +
      '.dss-pill{position:absolute;top:0;left:0;height:100%;border-radius:99px;pointer-events:none}' +
      '.dss-plot{position:relative;flex:1;min-height:0}' +
      '.dss-plot svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.dss-grid{stroke:currentColor;stroke-opacity:.1}' +
      '.dss-base{stroke:currentColor;stroke-opacity:.3}' +
      '.dss-tick{fill:currentColor;fill-opacity:.5;text-anchor:end}' +
      '.dss-lab{fill:currentColor;fill-opacity:.6;text-anchor:middle}' +
      '.dss-val{fill:currentColor;font-weight:700;text-anchor:middle}' +
      '.dss-line{fill:none;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}' +
      '.dss-dot{stroke:#fff;stroke-width:2}';
    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(max) {
      var raw = max / 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 rgb(hex) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }
    function mix(a, b, t) {
      return 'rgb(' + a.map(function (v, i) { return Math.round(v + (b[i] - v) * t); }).join(',') + ')';
    }
    function outBack(t) { var s = 1.4; t -= 1; return t * t * ((s + 1) * t + s) + 1; }
    function inOut(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
    function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }

    function setup(el) {
      var d = el.dataset;
      var cols = d.colors ? d.colors.split(',') : PALETTE;
      var sets = [];
      Array.prototype.forEach.call(el.children, function (s) {
        var vs = (s.getAttribute('data-values') || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
        if (!vs.length) return;
        var col = (s.getAttribute('data-color') || cols[sets.length % cols.length]).trim();
        sets.push({ name: s.textContent.trim(), vs: vs, color: col, rgb: rgb(col) });
      });
      if (!sets.length) return null;
      var n = Math.min.apply(null, sets.map(function (s) { return s.vs.length; }));
      var thumb = !!el.closest('[aria-hidden="true"]');
      var pre = d.prefix || '', suf = d.suffix || '';
      var dec = sets.some(function (s) { return s.vs.some(function (v) { return v % 1; }); }) ? 1 : 0;
      var c = {
        el: el, sets: sets, n: n, labels: d.labels ? d.labels.split(',') : [], cur: 0, t: 1,
        line: d.type === 'line', thumb: thumb, showVals: d.values !== 'false',
        pre: pre, suf: suf,
        fmt: function (v) { return pre + v.toFixed(dec) + suf; },
        dur: +d.duration || DEFAULTS.duration,
        stagger: d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger,
        autoplay: d.autoplay !== undefined && d.autoplay !== 'false'
      };
      sets.forEach(function (s) {
        var hi = Math.max.apply(null, s.vs.slice(0, n)) || 1;
        s.step = nice(hi);
        s.max = Math.ceil(hi * 1.05 / s.step) * s.step;
      });
      el.classList.add('dss');
      el.innerHTML = '';
      var tabs = document.createElement('div');
      tabs.className = 'dss-tabs';
      if (!thumb) tabs.setAttribute('role', 'group');
      c.pill = document.createElement('i');
      c.pill.className = 'dss-pill';
      tabs.appendChild(c.pill);
      sets.forEach(function (s, i) {
        var b = document.createElement(thumb ? 'span' : 'button');
        if (!thumb) { b.type = 'button'; b.setAttribute('aria-pressed', i === 0 ? 'true' : 'false'); }
        b.className = 'dss-tab' + (i === 0 ? ' dss-act' : '');
        b.innerHTML = '<span></span>';
        b.firstChild.textContent = s.name;
        b.addEventListener('click', function () { c.userAt = Date.now(); go(c, i); });
        tabs.appendChild(b);
        s.tab = b;
      });
      tabs.style.position = 'relative';
      el.appendChild(tabs);
      c.plot = document.createElement('div');
      c.plot.className = 'dss-plot';
      c.plot.setAttribute('role', 'img');
      el.appendChild(c.plot);
      el._dss = c;
      c.A = c.B = reduced() ? full(sets[0]) : zero(sets[0]);
      describe(c);
      layout(c);
      return c;
    }

    function describe(c) {
      var s = c.sets[c.cur];
      c.plot.setAttribute('aria-label', (c.line ? 'Line' : 'Bar') + ' chart, ' + s.name + ': ' +
        s.vs.slice(0, c.n).map(function (v, i) { return (c.labels[i] ? c.labels[i] + ' ' : '') + c.fmt(v); }).join(', ') + '.');
    }

    function movePill(c, instant) {
      var tab = c.sets[c.cur].tab;
      var props = { x: tab.offsetLeft, width: tab.offsetWidth, backgroundColor: c.sets[c.cur].color };
      if (instant || reduced()) {
        c.pill.style.transform = 'translateX(' + props.x + 'px)';
        c.pill.style.width = props.width + 'px';
        c.pill.style.background = props.backgroundColor;
        return;
      }
      anime.animate(c.pill, { x: props.x, width: props.width, backgroundColor: props.backgroundColor, duration: 450, ease: 'out(3)' });
    }

    function layout(c) {
      var W = c.plot.clientWidth, H = c.plot.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      var maxTick = Math.max.apply(null, c.sets.map(function (s) { return s.max; }));
      var left = Math.max(22, String(c.fmt(maxTick)).length * 6.2 + 8);
      var hasLab = c.labels.length > 0;
      c.box = { l: left, r: W - 4, t: 16, b: H - (hasLab ? 18 : 4) };
      c.gGrid = mk('g', {}, svg);
      mk('line', { class: 'dss-base', x1: left, x2: c.box.r, y1: c.box.b, y2: c.box.b }, svg);
      var slot = (c.box.r - left) / c.n;
      c.slot = slot;
      c.showV = c.showVals && slot >= 26;
      c.bars = [];
      var fs = slot < 30 ? 9.5 : 11;
      if (c.line) {
        c.area = mk('path', { 'fill-opacity': 0.14 }, svg);
        c.path = mk('path', { class: 'dss-line' }, svg);
      }
      for (var i = 0; i < c.n; i++) {
        var cx = left + slot * (i + 0.5), b = { cx: cx };
        if (c.line) b.dot = mk('circle', { class: 'dss-dot', cx: cx, r: Math.min(5, slot / 6) }, svg);
        else b.rect = mk('rect', { x: cx - slot * 0.32, width: slot * 0.64, rx: Math.min(4, slot * 0.1) }, svg);
        if (c.showV) b.val = mk('text', { class: 'dss-val', x: cx, 'font-size': fs }, svg);
        if (hasLab && c.labels[i] !== undefined) mk('text', { class: 'dss-lab', x: cx, y: H - 4, 'font-size': fs }, svg).textContent = c.labels[i];
        c.bars.push(b);
      }
      c.plot.innerHTML = '';
      c.plot.appendChild(svg);
      movePill(c, true);
      render(c);
    }

    function render(c) {
      if (!c.box || !c.A) return;
      var A = c.A, B = c.B, T = c.t, bx = c.box;
      var span = c.dur + c.stagger * (c.n - 1);
      var ka = T >= 1 ? 1 : inOut(clamp01(T / 0.8));
      var max = A.max + (B.max - A.max) * ka;
      var y = function (v) { return bx.b - v / max * (bx.b - bx.t); };
      // grid: ticks of whichever scale is nearer, placed on the moving scale
      c.gGrid.innerHTML = '';
      var step = ka < 0.5 ? A.step : B.step;
      for (var v = step; v <= max * 1.001; v += step) {
        var gy = y(v);
        mk('line', { class: 'dss-grid', x1: bx.l, x2: bx.r, y1: gy, y2: gy }, c.gGrid);
        mk('text', { class: 'dss-tick', x: bx.l - 6, y: gy, dy: '.35em', 'font-size': 10 }, c.gGrid).textContent = c.pre + (+v.toFixed(2)) + c.suf;
      }
      var pts = [], shown = [], cols = [];
      c.bars.forEach(function (b, i) {
        var p = T >= 1 ? 1 : clamp01((T * span - i * c.stagger) / c.dur);
        var k = p >= 1 ? 1 : outBack(p);
        var val = A.vs[i] + (B.vs[i] - A.vs[i]) * k;
        var rc = A.rgbs[i].map(function (x, j) { return x + (B.rgb[j] - x) * p; });
        var col = mix(rc, rc, 0);
        var top = y(Math.max(0, val));
        if (b.rect) {
          b.rect.setAttribute('y', top);
          b.rect.setAttribute('height', Math.max(0, bx.b - top));
          b.rect.setAttribute('fill', col);
        }
        if (b.dot) { b.dot.setAttribute('cy', top); b.dot.setAttribute('fill', col); }
        if (b.val) {
          b.val.setAttribute('y', top - (c.line ? 9 : 5));
          b.val.textContent = c.fmt(A.vs[i] + (B.vs[i] - A.vs[i]) * p);
          b.val.style.opacity = A.zero ? p : 1;
        }
        pts.push([b.cx, top]);
        shown.push(val); cols.push(rc);
      });
      c.shown = { vs: shown, rgbs: cols, max: max, step: step };
      if (c.line) {
        var dd = pts.map(function (q, i) { return (i ? 'L' : 'M') + q[0] + ',' + q[1]; }).join('');
        var lc = mix(cols[0], B.rgb, ka);
        c.path.setAttribute('d', dd);
        c.path.setAttribute('stroke', lc);
        c.area.setAttribute('d', dd + 'L' + pts[pts.length - 1][0] + ',' + bx.b + 'L' + pts[0][0] + ',' + bx.b + 'Z');
        c.area.setAttribute('fill', lc);
      }
    }

    function morph(c, A, B) {
      c.A = A; c.B = B;
      if (c.anim) c.anim.pause();
      if (reduced()) { c.t = 1; render(c); return; }
      c.t = 0;
      var o = { t: 0 };
      c.anim = anime.animate(o, {
        t: 1, duration: c.dur + c.stagger * (c.n - 1), ease: 'linear',
        onUpdate: function () { c.t = o.t; render(c); }
      });
    }

    function go(c, i) {
      if (i === c.cur) return;
      c.cur = i;
      c.sets.forEach(function (s, j) {
        s.tab.classList.toggle('dss-act', j === i);
        if (!c.thumb) s.tab.setAttribute('aria-pressed', j === i ? 'true' : 'false');
      });
      describe(c);
      movePill(c);
      morph(c, c.shown || full(c.sets[0]), c.sets[i]);
    }

    function full(s) {
      return { vs: s.vs, rgb: s.rgb, rgbs: s.vs.map(function () { return s.rgb; }), max: s.max, step: s.step };
    }

    function zero(s) {
      return { vs: s.vs.map(function () { return 0; }), rgb: s.rgb, rgbs: s.vs.map(function () { return s.rgb; }), max: s.max, step: s.step, zero: true };
    }

    function intro(c) {
      morph(c, zero(c.sets[0]), c.sets[0]);
    }

    function tick(c) {
      c.timer = null;
      if (!c.visible) return;
      if (!c.userAt || Date.now() - c.userAt > DEFAULTS.idle) go(c, (c.cur + 1) % c.sets.length);
      c.timer = setTimeout(function () { tick(c); }, DEFAULTS.interval);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._dss;
          c.visible = e.isIntersecting;
          if (!c.visible) return;
          if (!c.seen) { c.seen = true; if (!reduced()) intro(c); }
          if (c.autoplay && !reduced() && !c.timer && c.sets.length > 1) c.timer = setTimeout(function () { tick(c); }, DEFAULTS.interval);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target.parentNode._dss;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-data-set-switch]').forEach(function (el) {
        if (el._dss) return;
        var c = setup(el);
        if (!c) return;
        ro.observe(c.plot);
        io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.dataSetSwitch = function (el, i) { if (el && el._dss) { el._dss.userAt = Date.now(); go(el._dss, i); } };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-data-set-switch data-labels="Q1,Q2,Q3,Q4" data-prefix="$"
     style="height: 260px">
  <span data-values="64,88,96,82">2023</span>
  <span data-values="80,104,120,138">2024</span>
</div>

<!-- or as a line: -->
<div data-data-set-switch data-type="line" data-labels="Q1,Q2,Q3,Q4" style="height: 260px">…</div>