Chart Type Morph
The same figures shown two ways. The bars of a bar chart bend and fold round into the slices of a pie chart, then unfold back into bars, each one keeping its colour so you can follow it across. Use the buttons to switch it yourself.
MobileDesktopTabletTVOther
How to use it
- Paste the snippet just before
</head>. - Add
data-chart-type-morphto a container with a height, with one child per bar and slice: its text is the label anddata-valuethe figure.
Options
Add any of these attributes to the same element.
data-loop | Switch back and forth by itself. |
data-controls | Add Bar and Pie buttons. Using them stops the loop. |
data-start | pie to start as a pie chart. |
data-colors | Comma-separated colours, one per item. Default purple, pink, blue… |
data-prefix / data-suffix | Text around the bar values, e.g. %. |
data-duration | Time for each shape to morph. Default 900 ms. |
data-hold | Time to rest on each chart when looping. Default 2200 ms. |
From a script: animacious.chartTypeMorph(el, ‘pie’) or ‘bar’; leave the second argument out to toggle.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Chart Type Morph: the same figures shown two ways. The bars of a bar
// chart bend and fold round into the slices of a pie chart, then unfold
// back into bars, each keeping its colour so you can follow it across.
// Usage:
// <div data-chart-type-morph data-loop style="height:240px">
// <span data-value="42">Mon</span>
// <span data-value="28">Tue</span>
// </div>
// Each child is one bar and one slice: its text is the label.
// Optional: data-loop (switch back and forth by itself), data-controls
// (adds Bar / Pie buttons; using them stops the loop), data-start="pie",
// data-colors="#a855f7,#ec4899,…", data-prefix / data-suffix (around the
// bar values), data-duration (ms for each shape to morph, default 900),
// data-hold (ms to rest on each chart when looping, default 2200).
// Script use: animacious.chartTypeMorph(el, 'pie' | 'bar')
var DEFAULTS = { duration: 900, hold: 2200, stagger: 70 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
var NS = 'http://www.w3.org/2000/svg';
var N1 = 8, N2 = 28, N3 = 4;
var css =
'.ctm{display:flex;flex-direction:column;gap:10px;font-size:11px;font-variant-numeric:tabular-nums}' +
'.ctm-plot{position:relative;flex:1;min-height:0}' +
'.ctm-plot svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.ctm-base{stroke:currentColor;stroke-opacity:.3}' +
'.ctm-shape{stroke:#fff;stroke-width:1.5;stroke-linejoin:round}' +
'.ctm-txt{fill:currentColor}' +
'.ctm-val{font-weight:700}' +
'.ctm-lab{fill-opacity:.65}' +
'.ctm-pct{fill:#fff;font-weight:700;font-size:12px}' +
'.ctm-name{font-weight:600}' +
'.ctm-ctrl{align-self:center;display:inline-flex;padding:3px;border-radius:999px;background:color-mix(in srgb,currentColor 8%,transparent)}' +
'.ctm-ctrl button{font:inherit;font-size:13px;font-weight:600;color:inherit;border:0;background:none;padding:5px 14px;border-radius:999px;cursor:pointer;opacity:.65}' +
'.ctm-ctrl button[aria-pressed="true"]{background:#fff;opacity:1;box-shadow:0 1px 4px rgba(0,0,0,.12)}';
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; }
function inOut(x) { x = clamp01(x); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; }
function setup(el) {
var d = el.dataset;
var colors = d.colors ? d.colors.split(',') : PALETTE;
var items = [];
Array.prototype.forEach.call(el.children, function (b) {
var v = parseFloat(b.getAttribute('data-value'));
if (isNaN(v) || v < 0) return;
items.push({ label: b.textContent.trim(), v: v, color: b.getAttribute('data-color') || colors[items.length % colors.length] });
});
var total = items.reduce(function (a, it) { return a + it.v; }, 0) || 1;
var a = 0;
items.forEach(function (it) { it.pct = it.v / total; it.a0 = a; a += it.pct * Math.PI * 2; it.a1 = a; });
var pre = d.prefix || '', suf = d.suffix || '';
var c = { el: el, items: items, pre: pre, suf: suf, k: items.map(function () { return d.start === 'pie' ? 1 : 0; }), grow: 1, mode: d.start === 'pie' ? 'pie' : 'bar' };
c.max = Math.max.apply(null, items.map(function (it) { return it.v; })) || 1;
el.classList.add('ctm');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Chart switching between bars and a pie. ' + items.map(function (it) {
return it.label + ': ' + pre + it.v + suf + ' (' + Math.round(it.pct * 100) + '%)';
}).join(', ') + '.');
el.innerHTML = '<div class="ctm-plot" aria-hidden="true"></div>';
c.plot = el.firstChild;
if (d.controls !== undefined && d.controls !== 'false') {
var ctrl = document.createElement('div');
ctrl.className = 'ctm-ctrl';
ctrl.setAttribute('role', 'group');
ctrl.setAttribute('aria-label', 'Chart type');
ctrl.innerHTML = '<button type="button" data-mode="bar">Bar</button><button type="button" data-mode="pie">Pie</button>';
el.appendChild(ctrl);
c.buttons = Array.prototype.slice.call(ctrl.querySelectorAll('button'));
c.buttons.forEach(function (b) {
b.addEventListener('click', function () { c.user = true; clearTimeout(c.timer); morph(c, b.getAttribute('data-mode')); });
});
el.removeAttribute('role');
el.removeAttribute('aria-label');
c.plot.removeAttribute('aria-hidden');
c.plot.setAttribute('role', 'img');
c.plot.setAttribute('aria-label', 'Bar chart and pie chart of the same figures. ' + items.map(function (it) {
return it.label + ': ' + pre + it.v + suf + ' (' + Math.round(it.pct * 100) + '%)';
}).join(', ') + '.');
}
el._ctm = c;
layout(c);
press(c);
return c;
}
function press(c) {
if (c.buttons) c.buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-mode') === c.mode)); });
}
function layout(c) {
var W = c.plot.clientWidth, H = c.plot.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var n = c.items.length, top = 18, base = H - 18;
var slot = W / n, bw = Math.min(54, slot * 0.64);
var longest = Math.max.apply(null, c.items.map(function (it) { return it.label.length; }));
var R = Math.max(30, Math.min(H / 2 - 4, W / 2 - longest * 6.6 - 14));
c.geo = { base: base, R: R, cx: W / 2, cy: H / 2 };
c.items.forEach(function (it, i) {
it.x0 = slot * (i + 0.5) - bw / 2;
it.x1 = it.x0 + bw;
it.top = base - (base - top) * it.v / c.max;
});
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H });
c.baseLine = mk('line', { class: 'ctm-base', x1: 0, x2: W, y1: base + .5, y2: base + .5 }, svg);
c.items.forEach(function (it, i) {
it.shape = mk('path', { class: 'ctm-shape', fill: it.color }, svg);
var cx = (it.x0 + it.x1) / 2;
it.val = mk('text', { class: 'ctm-txt ctm-val', x: cx, y: it.top - 6, 'text-anchor': 'middle' }, svg);
it.val.textContent = c.pre + it.v.toLocaleString() + c.suf;
it.lab = mk('text', { class: 'ctm-txt ctm-lab', x: cx, y: H - 3, 'text-anchor': 'middle' }, svg);
it.lab.textContent = it.label;
var mid = (it.a0 + it.a1) / 2, sx = Math.sin(mid), sy = -Math.cos(mid);
it.pctT = mk('text', { class: 'ctm-txt ctm-pct', x: c.geo.cx + R * 0.64 * sx, y: c.geo.cy + R * 0.64 * sy, dy: '.35em', 'text-anchor': 'middle' }, svg);
it.pctT.textContent = it.pct >= 0.06 ? Math.round(it.pct * 100) + '%' : '';
it.name = mk('text', {
class: 'ctm-txt ctm-name', x: c.geo.cx + (R + 8) * sx, y: c.geo.cy + (R + 8) * sy, dy: '.35em',
'text-anchor': Math.abs(sx) < 0.25 ? 'middle' : sx > 0 ? 'start' : 'end'
}, svg);
if (Math.abs(sx) < 0.25) it.name.setAttribute('dy', sy < 0 ? '-.15em' : '.9em');
it.name.textContent = it.label;
});
c.plot.innerHTML = '';
c.plot.appendChild(svg);
render(c);
}
// The outline of a bar and of its slice, point for point.
function barPts(c, it) {
var b = c.geo.base, t = b - (b - it.top) * c.grow, pts = [], i;
for (i = 0; i < N1; i++) pts.push([it.x0, b + (t - b) * i / N1]);
for (i = 0; i < N2; i++) pts.push([it.x0 + (it.x1 - it.x0) * i / N2, t]);
for (i = 0; i < N1; i++) pts.push([it.x1, t + (b - t) * i / N1]);
for (i = 0; i < N3; i++) pts.push([it.x1 - (it.x1 - it.x0) * i / N3, b]);
return pts;
}
function piePts(c, it) {
var g = c.geo, pts = [], i;
function at(a, r) { return [g.cx + r * Math.sin(a), g.cy - r * Math.cos(a)]; }
for (i = 0; i < N1; i++) pts.push(at(it.a0, g.R * i / N1));
for (i = 0; i < N2; i++) pts.push(at(it.a0 + (it.a1 - it.a0) * i / N2, g.R));
for (i = 0; i < N1; i++) pts.push(at(it.a1, g.R * (1 - i / N1)));
for (i = 0; i < N3; i++) pts.push([g.cx, g.cy]);
return pts;
}
function render(c) {
if (!c.geo) return;
var all = 0;
c.items.forEach(function (it, i) {
var k = inOut(c.k[i]);
all += c.k[i];
var a = barPts(c, it), b = piePts(c, it);
var d = 'M' + a.map(function (p, j) {
return (p[0] + (b[j][0] - p[0]) * k).toFixed(1) + ' ' + (p[1] + (b[j][1] - p[1]) * k).toFixed(1);
}).join('L') + 'Z';
it.shape.setAttribute('d', d);
var barT = clamp01(1 - c.k[i] * 3) * clamp01((c.grow - 0.85) * 7);
it.val.style.opacity = barT;
it.lab.style.opacity = clamp01(1 - c.k[i] * 3);
it.pctT.style.opacity = clamp01((c.k[i] - 0.75) * 4);
it.name.style.opacity = clamp01((c.k[i] - 0.75) * 4);
});
c.baseLine.style.opacity = clamp01(1 - all / c.items.length * 2);
}
function morph(c, mode) {
c.mode = mode;
press(c);
var d = c.el.dataset, dur = +d.duration || DEFAULTS.duration, to = mode === 'pie' ? 1 : 0;
if (c.anim) c.anim.pause();
if (reduced()) { c.k = c.k.map(function () { return to; }); render(c); return null; }
var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
c.items.forEach(function (it, i) {
var s = { k: c.k[i] };
var order = mode === 'pie' ? i : c.items.length - 1 - i;
tl.add(s, { k: to, duration: dur, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, order * DEFAULTS.stagger);
});
c.anim = tl;
return tl;
}
function cycle(c) {
var d = c.el.dataset, hold = +d.hold || DEFAULTS.hold;
c.timer = setTimeout(function () {
if (c.user) return;
var tl = morph(c, c.mode === 'pie' ? 'bar' : 'pie');
if (tl) tl.then(function () { if (!c.user) cycle(c); });
}, hold);
}
function play(c) {
var d = c.el.dataset;
c.grow = 0;
render(c);
anime.animate(c, {
grow: 1, duration: 900, ease: 'outBack(1.2)', onUpdate: function () { render(c); },
onComplete: function () { if (d.loop !== undefined && d.loop !== 'false') cycle(c); }
});
}
window.animacious = window.animacious || {};
window.animacious.chartTypeMorph = function (el, mode) {
var c = el && el._ctm;
if (!c) return;
c.user = true;
clearTimeout(c.timer);
morph(c, mode || (c.mode === 'pie' ? 'bar' : 'pie'));
};
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._ctm);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._ctm;
if (c && (c.plot.clientWidth !== c.W || c.plot.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-chart-type-morph]').forEach(function (el) {
if (el._ctm) return;
var c = setup(el);
ro.observe(el);
if (reduced()) return;
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-chart-type-morph data-controls data-suffix="%" style="height: 260px">
<span data-value="46">Mobile</span>
<span data-value="31">Desktop</span>
<span data-value="15">Tablet</span>
<span data-value="8">Other</span>
</div>