← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-chart-type-morph to a container with a height, with one child per bar and slice: its text is the label and data-value the figure.

Options

Add any of these attributes to the same element.

data-loopSwitch back and forth by itself.
data-controlsAdd Bar and Pie buttons. Using them stops the loop.
data-startpie to start as a pie chart.
data-colorsComma-separated colours, one per item. Default purple, pink, blue…
data-prefix / data-suffixText around the bar values, e.g. %.
data-durationTime for each shape to morph. Default 900 ms.
data-holdTime 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.

1 · Snippet
<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>
2 · Your HTML
<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>