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
- Paste the snippet just before
</head>. - Add
data-data-set-switchanddata-labelsto a container with a height, with one child per data set: its text is the tab name anddata-valuesits figures.
Options
Add any of these attributes to the same element.
data-type | bars or line. Default bars. |
data-colors | Comma-separated colours, one per data set. Default purple, pink, blue… |
data-color | On a data set: its colour. |
data-prefix / data-suffix | Text around the figures, e.g. $ and k. |
data-values | On the chart: false hides the figures. They hide themselves when bars get thin. |
data-duration | Time for each bar to morph. Default 700 ms. |
data-stagger | Gap between bars. Default 45 ms. |
data-autoplay | Switch 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).
<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><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>