← Charts

Pie Sweep

A pie chart whose slices sweep round from twelve o’clock like a clock hand, each share appearing in its slice as the sweep passes and the legend filling in alongside. Point at a slice to pull it out.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pie-sweep to a container with one child per slice, each with data-value and data-label.

Options

Add any of these attributes to the same element.

data-colorOn a slice: its colour. Default a purple, pink, blue… set.
data-sizeWidth of the pie. Default 200 px.
data-hole0–0.8: cut a hole to make a donut. Default 0.
data-totalWith a hole: text for the middle. Leave it empty to show the sum.
data-captionWhat the chart shows, for screen readers.
data-prefix / data-suffixAround the values, e.g. $.
data-loopSweep away and round again every few seconds.
data-durationTime for the sweep. Default 1400 ms.

Screen readers get the values and shares as a proper table, with the drawing hidden from them. With reduced motion the finished chart simply appears.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pie Sweep: a pie chart whose slices sweep round from twelve o'clock like
    // a clock hand, each share appearing in its slice as the sweep passes it
    // and the legend filling in alongside. Point at a slice or a legend entry
    // to pull that slice out.
    // Usage:
    //   <div data-pie-sweep data-caption="Where the money goes">
    //     <span data-value="45" data-label="Rent"></span>
    //     <span data-value="25" data-label="Food"></span>
    //     <span data-value="18" data-label="Travel"></span>
    //     <span data-value="12" data-label="Other"></span>
    //   </div>
    // Optional: data-color on a slice (default: a purple, pink, blue… set),
    // data-size (px across, default 200), data-hole (0–0.8: cut a hole for a
    // donut, default 0), data-total (with a hole: text for the middle; leave
    // it empty to show the sum), data-caption (what the chart shows, for screen
    // readers), data-prefix / data-suffix (around the values in the table,
    // e.g. "$"), data-duration (ms, default 1400), data-loop (sweep away and
    // round again every few seconds).
    // Screen readers get the numbers as a proper table; the drawing is hidden
    // from them.
    var DEFAULTS = { size: 200, hole: 0, duration: 1400, hold: 2800 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e'];
    var TAU = Math.PI * 2;

    var css =
      '.pie{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:16px 28px;--pie-size:200px}' +
      '.pie-svg{flex:none;width:min(100%,var(--pie-size));aspect-ratio:1;overflow:visible}' +
      '.pie-svg g{transition:opacity .25s}' +
      '.pie-svg text{font:700 11px system-ui,-apple-system,"Segoe UI",sans-serif;fill:#fff;text-anchor:middle;dominant-baseline:central;pointer-events:none}' +
      '.pie-legend{display:grid;gap:8px;margin:0;padding:0;list-style:none;font-size:14px;text-align:left}' +
      '.pie-legend li{display:flex;align-items:center;gap:10px;cursor:default}' +
      '.pie-legend i{flex:none;width:12px;height:12px;border-radius:4px}' +
      '.pie-legend b{margin-left:auto;padding-left:12px;font-variant-numeric:tabular-nums}' +
      '.pie-legend span{opacity:.75}' +
      '.pie-mid{font:800 22px system-ui,-apple-system,"Segoe UI",sans-serif !important;fill:currentColor !important}' +
      '.pie-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    function pt(r, a) { return [100 + r * Math.sin(a), 100 - r * Math.cos(a)]; }

    // One slice from angle a0 to a1 (radians clockwise from twelve o'clock).
    function arc(a0, a1, r, h) {
      if (a1 - a0 >= TAU - 1e-4) a1 = a0 + TAU - 1e-4;
      var big = a1 - a0 > Math.PI ? 1 : 0;
      var p0 = pt(r, a0), p1 = pt(r, a1);
      var d = 'M' + p0 + 'A' + r + ' ' + r + ' 0 ' + big + ' 1 ' + p1;
      if (!h) return d + 'L100 100Z';
      var q1 = pt(h, a1), q0 = pt(h, a0);
      return d + 'L' + q1 + 'A' + h + ' ' + h + ' 0 ' + big + ' 0 ' + q0 + 'Z';
    }

    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }

    function build(el) {
      var d = el.dataset;
      var items = Array.from(el.children).map(function (c, i) {
        return { value: Math.max(0, +c.dataset.value || 0), label: c.dataset.label || '', color: c.dataset.color || PALETTE[i % PALETTE.length] };
      });
      var total = items.reduce(function (s, it) { return s + it.value; }, 0) || 1;
      var hole = Math.max(0, Math.min(0.8, +d.hole || DEFAULTS.hole)) * 92;
      var a = 0;
      items.forEach(function (it) {
        it.share = it.value / total;
        it.a0 = a;
        a += it.share * TAU;
        it.a1 = a;
        it.mid = (it.a0 + it.a1) / 2;
        it.pct = Math.round(it.share * 100) + '%';
      });
      var pre = d.prefix || '', suf = d.suffix || '';

      el.classList.add('pie');
      el.style.setProperty('--pie-size', (+d.size || DEFAULTS.size) + 'px');
      el.innerHTML =
        '<table class="pie-sr"><caption>' + esc(d.caption || 'Pie chart') + '</caption>' +
        '<thead><tr><th scope="col">Item</th><th scope="col">Value</th><th scope="col">Share</th></tr></thead><tbody>' +
        items.map(function (it) { return '<tr><th scope="row">' + esc(it.label) + '</th><td>' + esc(pre + it.value + suf) + '</td><td>' + it.pct + '</td></tr>'; }).join('') +
        '</tbody></table>' +
        '<svg class="pie-svg" viewBox="0 0 200 200" aria-hidden="true"></svg>' +
        '<ul class="pie-legend" aria-hidden="true">' +
        items.map(function (it) { return '<li><i style="background:' + esc(it.color) + '"></i><span>' + esc(it.label) + '</span><b>' + it.pct + '</b></li>'; }).join('') +
        '</ul>';
      var svg = el.querySelector('svg');
      var rows = Array.from(el.querySelectorAll('.pie-legend li'));
      items.forEach(function (it, i) {
        var g = document.createElementNS(NS, 'g');
        var path = document.createElementNS(NS, 'path');
        path.setAttribute('fill', it.color);
        path.setAttribute('stroke', d.stroke || '#fff');
        path.setAttribute('stroke-width', '2');
        path.setAttribute('stroke-linejoin', 'round');
        g.appendChild(path);
        // The share goes in the slice, if the slice is big enough to hold it.
        if (it.share >= 0.07) {
          var t = document.createElementNS(NS, 'text');
          var at = pt(hole ? (92 + hole) / 2 : 58, it.mid);
          t.setAttribute('x', at[0]);
          t.setAttribute('y', at[1]);
          t.textContent = it.pct;
          g.appendChild(t);
          it.text = t;
        }
        svg.appendChild(g);
        it.g = g;
        it.path = path;
        it.row = rows[i];
        var on = function () { focus(c, i); };
        var off = function () { focus(c, -1); };
        g.addEventListener('pointerenter', on);
        g.addEventListener('pointerleave', off);
        rows[i].addEventListener('pointerenter', on);
        rows[i].addEventListener('pointerleave', off);
      });
      if (hole && d.total !== undefined) {
        var mid = document.createElementNS(NS, 'text');
        mid.setAttribute('x', 100);
        mid.setAttribute('y', 100);
        mid.setAttribute('class', 'pie-mid');
        mid.textContent = d.total || pre + total.toLocaleString() + suf;
        svg.appendChild(mid);
      }
      var c = { el: el, items: items, svg: svg, hole: hole, state: { p: 0 }, pulled: -1 };
      return c;
    }

    function render(c) {
      var sweep = c.state.p * TAU;
      c.items.forEach(function (it) {
        var shown = sweep > it.a0 + 1e-3;
        it.path.setAttribute('d', shown ? arc(it.a0, Math.min(it.a1, sweep), 92, c.hole) : '');
        // Labels and legend entries arrive as the sweep reaches them.
        var t = Math.max(0, Math.min(1, (sweep - it.mid) / 0.5));
        if (it.text) { it.text.style.opacity = t; }
        it.row.style.opacity = 0.25 + 0.75 * Math.max(0, Math.min(1, (sweep - it.a0) / 0.4));
      });
    }

    function focus(c, i, force) {
      if (!force && (c.state.p < 1 || i === c.pulled)) return;
      c.pulled = i;
      var rm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      c.items.forEach(function (it, j) {
        var out = j === i ? pt(8, it.mid) : [100, 100];
        anime.animate(it.g, { translateX: out[0] - 100, translateY: out[1] - 100, duration: rm ? 0 : 420, ease: 'outBack(2)' });
        it.g.style.opacity = i < 0 || j === i ? 1 : 0.45;
        it.row.style.fontWeight = j === i ? 700 : '';
      });
    }

    function sweep(c) {
      var d = c.el.dataset;
      var looping = d.loop !== undefined && d.loop !== 'false';
      anime.animate(c.svg, { scale: [0.86, 1], rotate: [-12, 0], duration: (+d.duration || DEFAULTS.duration) * 0.9, ease: 'out(3)' });
      anime.animate(c.state, {
        p: [0, 1], duration: +d.duration || DEFAULTS.duration, ease: 'inOut(2)', onUpdate: function () { render(c); },
        onComplete: function () {
          if (!looping) return;
          setTimeout(function () {
            focus(c, -1, true);
            // Sweep away (backwards), then round again.
            anime.animate(c.state, { p: 0, duration: 650, ease: 'in(2)', onUpdate: function () { render(c); }, onComplete: function () { setTimeout(function () { sweep(c); }, 350); } });
          }, DEFAULTS.hold);
        }
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          sweep(e.target._pie);
        });
      }, { threshold: 0.4 });
      document.querySelectorAll('[data-pie-sweep]').forEach(function (el) {
        if (el._pie) return;
        var c = (el._pie = build(el));
        if (reduced) { c.state.p = 1; render(c); return; }
        render(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pie-sweep data-caption="Where the money goes">
  <span data-value="45" data-label="Rent"></span>
  <span data-value="25" data-label="Food"></span>
  <span data-value="18" data-label="Travel"></span>
  <span data-value="12" data-label="Other"></span>
</div>