← Charts

Sunburst Chart

A hierarchy drawn as rings: the top level round the middle and each part’s pieces round the outside of it. The rings sweep round from twelve o’clock one after another, inside first, with the total in the centre. Point at or tap a segment to see its value and share there instead.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sunburst-chart="A > B: amount, …" to an element with a height, listing each leaf as a path with its amount. Each ring shows the totals of the level below it.

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, one per top-level part. Default purple, pink, blue…
data-labelText under the total in the middle. Default Total.
data-prefix / data-suffixText around the values, e.g. $ and k.
data-labelsfalse hides the names on the segments.
data-durationTime for each ring to sweep round. Default 1000 ms.
data-loopSweep away and round again every few seconds.

Names only appear on segments with room for them; the outer pieces take lighter shades of their parent’s colour. Screen readers hear every path and amount.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sunburst Chart: a hierarchy drawn as rings, the top level in the middle
    // and each level's parts round the outside of its parent. The rings sweep
    // round from twelve o'clock one after another, inside first, and labels
    // fade in where they fit. The total sits in the middle; point at or tap a
    // segment to see its name, value and share there instead.
    // Usage:
    //   <div data-sunburst-chart="Product > Web: 40, Product > Mobile: 30,
    //        Sales > Europe: 25, Sales > Asia: 18, Support: 12"
    //        style="height:280px"></div>
    // List the leaves as paths, "A > B > C: amount", separated by commas. Each
    // ring shows the totals of the level below it.
    // Optional: data-colors (comma-separated hex colours, one per top-level
    // part; default purple, pink, blue…), data-label (text under the total in
    // the middle, default "Total"), data-prefix / data-suffix (around the
    // values), data-labels="false" (no labels on the segments), data-duration
    // (ms for each ring's sweep, default 1000), data-loop (sweep away and
    // round again every few seconds).
    var DEFAULTS = { duration: 1000, hold: 3000, label: 'Total' };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var TAU = Math.PI * 2, HOLE = 0.3, GAP = 0.012;

    var css =
      '.snb{position:relative;display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}' +
      '.snb-wrap{position:relative;flex:none}' +
      '.snb-wrap svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.snb-seg{transition:opacity .25s;cursor:pointer}' +
      '.snb-dim .snb-seg:not(.snb-on){opacity:.35}' +
      '.snb-mid{position:absolute;left:50%;top:50%;width:52%;transform:translate(-50%,-50%);text-align:center;line-height:1.1;pointer-events:none}' +
      '.snb-mid b{display:block;font-size:var(--snb-big);font-weight:800;white-space:nowrap}' +
      '.snb-mid span{display:block;font-size:var(--snb-small);opacity:.65;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.snb-lab{position:absolute;transform:translate(-50%,-50%);color:#fff;font-size:11px;font-weight:700;white-space:nowrap;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.25);opacity:0}' +
      '.snb-lab.snb-hide{display:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
    function lighten(hex, t) {
      var n = parseInt(hex.slice(1), 16), rgb = [n >> 16, (n >> 8) & 255, n & 255];
      return 'rgb(' + rgb.map(function (v) { return Math.round(v + (255 - v) * t); }).join(',') + ')';
    }

    // An annulus segment between radii r0 and r1, angles a0 to a1 (0 = twelve o'clock).
    function arc(r0, r1, a0, a1) {
      if (a1 - a0 < 0.0005) return 'M0 0';
      var big = a1 - a0 > Math.PI ? 1 : 0;
      function p(r, a) { return (Math.sin(a) * r).toFixed(2) + ' ' + (-Math.cos(a) * r).toFixed(2); }
      return 'M' + p(r1, a0) + 'A' + r1 + ' ' + r1 + ' 0 ' + big + ' 1 ' + p(r1, a1) +
        'L' + p(r0, a1) + 'A' + r0 + ' ' + r0 + ' 0 ' + big + ' 0 ' + p(r0, a0) + 'Z';
    }

    function parse(str) {
      var root = { name: '', value: 0, kids: [] };
      str.split(',').forEach(function (part) {
        var m = part.split(':');
        if (m.length < 2) return;
        var v = +m[1] || 0, node = root;
        m[0].split('>').map(function (s) { return s.trim(); }).filter(Boolean).forEach(function (name) {
          var k = node.kids.filter(function (x) { return x.name === name; })[0];
          if (!k) { k = { name: name, value: 0, kids: [], parent: node }; node.kids.push(k); }
          node = k;
        });
        for (var n = node; n; n = n.parent) n.value += v;
      });
      return root;
    }

    function setup(el) {
      var d = el.dataset;
      var root = parse(el.getAttribute('data-sunburst-chart') || '');
      var colors = d.colors ? d.colors.split(',').map(function (s) { return s.trim(); }) : PALETTE;
      var pre = d.prefix || '', suf = d.suffix || '';
      var segs = [], depth = 0;
      // Lay out angles and colours, level by level.
      (function walk(node, a0, lvl, color) {
        var a = a0;
        node.kids.forEach(function (k, i) {
          var span = (k.value / (node.value || 1)) * (node === root ? TAU : node.a1 - node.a0);
          k.a0 = a; k.a1 = a + span; k.lvl = lvl;
          k.color = lvl === 0 ? lighten(colors[i % colors.length], 0) : lighten(color, Math.min(0.7, 0.22 * lvl + 0.1 * (i % 3)));
          k.base = lvl === 0 ? colors[i % colors.length] : color;
          segs.push(k);
          depth = Math.max(depth, lvl + 1);
          walk(k, a, lvl + 1, k.base);
          a += span;
        });
      })(root, 0, 0, null);
      var ring = (1 - HOLE) / depth;
      segs.forEach(function (s) {
        s.r0 = 100 * (HOLE + s.lvl * ring) + 1;
        s.r1 = 100 * (HOLE + (s.lvl + 1) * ring);
      });
      var fmt = function (v) { return pre + v.toLocaleString() + suf; };

      el.classList.add('snb');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Sunburst chart, total ' + fmt(root.value) + '. ' + segs.map(function (s) {
        var path = [], n = s;
        for (; n && n !== root; n = n.parent) path.unshift(n.name);
        return path.join(' > ') + ': ' + fmt(s.value);
      }).join(', '));
      el.innerHTML = '<div class="snb-wrap" aria-hidden="true"><svg viewBox="-100 -100 200 200">' +
        segs.map(function (s) { return '<path class="snb-seg" d="M0 0" fill="' + s.color + '"/>'; }).join('') +
        '</svg><div class="snb-mid"><b></b><span></span></div>' +
        (d.labels === 'false' ? '' : segs.map(function (s) {
          var a = (s.a0 + s.a1) / 2, r = (s.r0 + s.r1) / 2;
          return '<span class="snb-lab" style="left:' + (50 + Math.sin(a) * r / 2).toFixed(2) + '%;top:' + (50 - Math.cos(a) * r / 2).toFixed(2) + '%">' + esc(s.name) + '</span>';
        }).join('')) + '</div>';

      var c = { el: el, root: root, segs: segs, depth: depth, sweep: [], fmt: fmt, label: d.label || DEFAULTS.label };
      for (var i = 0; i < depth; i++) c.sweep.push(0);
      c.wrap = el.querySelector('.snb-wrap');
      c.paths = Array.prototype.slice.call(el.querySelectorAll('.snb-seg'));
      c.labs = Array.prototype.slice.call(el.querySelectorAll('.snb-lab'));
      c.big = el.querySelector('.snb-mid b');
      c.small = el.querySelector('.snb-mid span');
      centre(c, null);

      c.paths.forEach(function (p, i) {
        function on() {
          c.wrap.classList.add('snb-dim');
          c.paths.forEach(function (x, j) {
            // Light up the segment and the chain of parents it belongs to.
            var s = c.segs[j], n = c.segs[i], hit = false;
            for (; n; n = n.parent) if (n === s) hit = true;
            x.classList.toggle('snb-on', hit);
          });
          centre(c, c.segs[i]);
        }
        p.addEventListener('pointerenter', on);
        p.addEventListener('pointerdown', on);
        p.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') off(c); });
      });
      document.addEventListener('pointerdown', function (e) { if (!c.wrap.contains(e.target)) off(c); });
      el._snb = c;
      size(c);
      if (window.ResizeObserver) new ResizeObserver(function () { size(c); }).observe(el);
      return c;
    }

    function off(c) { c.wrap.classList.remove('snb-dim'); centre(c, null); }

    function centre(c, s) {
      if (!s) { c.big.textContent = c.fmt(c.root.value); c.small.textContent = c.label; return; }
      c.big.textContent = c.fmt(s.value);
      c.small.textContent = s.name + ' · ' + Math.round((s.value / (c.root.value || 1)) * 100) + '%';
    }

    // Square, as big as the box allows; labels only where they fit.
    function size(c) {
      var px = Math.max(60, Math.min(c.el.clientWidth, c.el.clientHeight));
      c.wrap.style.width = c.wrap.style.height = px + 'px';
      c.wrap.style.setProperty('--snb-big', Math.max(12, px * 0.075) + 'px');
      c.wrap.style.setProperty('--snb-small', Math.max(9, px * 0.04) + 'px');
      var u = px / 200;
      c.labs.forEach(function (l, i) {
        var s = c.segs[i];
        var room = Math.min((s.a1 - s.a0) * ((s.r0 + s.r1) / 2) * u, (s.r1 - s.r0) * u * 2.2);
        l.classList.toggle('snb-hide', (s.r1 - s.r0) * u < 15 || l.textContent.length * 6.4 + 8 > room);
      });
    }

    function render(c) {
      c.segs.forEach(function (s, i) {
        var end = Math.min(s.a1, c.sweep[s.lvl]) - GAP;
        c.paths[i].setAttribute('d', end > s.a0 + GAP ? arc(s.r0, s.r1, s.a0 + GAP, end) : 'M0 0');
      });
    }

    function finish(c) {
      for (var i = 0; i < c.depth; i++) c.sweep[i] = TAU;
      render(c);
      c.labs.forEach(function (l) { l.style.opacity = 1; });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var tl = anime.createTimeline();
      var st = c.sweep.map(function () { return { a: 0 }; });
      c.sweep = c.sweep.map(function () { return 0; });
      render(c);
      st.forEach(function (o, lvl) {
        tl.add(o, {
          a: TAU, duration: duration, ease: 'inOut(2)',
          onUpdate: function () { c.sweep[lvl] = o.a; render(c); }
        }, lvl * duration * 0.45);
      });
      c.labs.forEach(function (l, i) {
        var s = c.segs[i];
        tl.add(l, { opacity: [0, 1], duration: 300 }, s.lvl * duration * 0.45 + duration * (0.3 + 0.6 * (s.a1 / TAU)));
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            off(c);
            anime.animate(c.labs, { opacity: 0, duration: 250 });
            var o = { a: TAU };
            anime.animate(o, {
              a: 0, duration: 600, ease: 'in(2)',
              onUpdate: function () { c.sweep = c.sweep.map(function () { return o.a; }); render(c); },
              onComplete: function () { setTimeout(function () { play(c); }, 250); }
            });
          }, DEFAULTS.hold);
        });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._snb);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-sunburst-chart]').forEach(function (el) {
        if (el._snb) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        render(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sunburst-chart="Product > Web: 40, Product > Mobile: 30,
     Sales > Europe: 25, Sales > Asia: 18, Support: 12"
     style="height: 280px"></div>