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
- Paste the snippet just before
</head>. - Add
data-pie-sweepto a container with one child per slice, each withdata-valueanddata-label.
Options
Add any of these attributes to the same element.
data-color | On a slice: its colour. Default a purple, pink, blue… set. |
data-size | Width of the pie. Default 200 px. |
data-hole | 0–0.8: cut a hole to make 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, e.g. $. |
data-loop | Sweep away and round again every few seconds. |
data-duration | Time 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.
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>