← Charts

Polar Area Chart

A rose of equal slices, one per item. They grow outwards from the centre one after another, round from twelve o’clock, each springing out to its value and settling. Point at or tap a slice to pick it out.

MonTueWedThuFriSatSun

How to use it

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

Options

Add any of these attributes to the same element.

data-colorOn a slice: its colour. Default a purple, pink, blue… set.
data-scaleradius makes the length match the value. Default: the area does.
data-maxValue at the outer ring. Default the largest.
data-labelsfalse hides the labels round the edge.
data-prefix / data-suffixText around the values.
data-durationTime for each slice to grow. Default 900 ms.
data-staggerGap between slices. Default 110 ms.
data-loopShrink back and grow again every few seconds.

By default the area of each slice matches its value, so a value twice as big doesn’t look four times as big. Screen readers hear every slice and its value.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Polar Area Chart: a rose of equal-angled segments that grow outwards from
    // the centre one after another, round from twelve o'clock, each springing
    // out to its value and settling. Point at (or tap) a segment to pick it out.
    // Usage:
    //   <div data-polar-area-chart style="height:280px">
    //     <span data-value="42">Mon</span>
    //     <span data-value="28">Tue</span>
    //     <span data-value="35">Wed</span>
    //   </div>
    // Each child is one segment: its text is the label and data-value its size.
    // Optional: data-color (on a segment: its colour; default a purple, pink,
    // blue… set), data-scale="radius" (length matches the value; default the
    // area does, which is the honest way), data-max (value at the outer ring,
    // default the largest), data-labels="false" (no labels round the edge),
    // data-prefix / data-suffix (around the values), data-duration (ms for
    // each segment, default 900), data-stagger (ms between segments, default
    // 110), data-loop (shrink back and grow again every few seconds).
    var DEFAULTS = { duration: 900, stagger: 110, hold: 2800 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var R = 100, BOX = 128;

    var css =
      '.pac{position:relative;display:flex;align-items:center;justify-content:center}' +
      '.pac-wrap{position:relative;flex:none}' +
      '.pac-wrap svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.pac-seg{transition:opacity .25s;cursor:pointer}' +
      '.pac-dim .pac-seg:not(.pac-on){opacity:.3}' +
      '.pac-lab{position:absolute;transform:translate(-50%,-50%);text-align:center;font-size:11px;font-weight:600;line-height:1.15;white-space:nowrap;opacity:0;transition:opacity .25s;pointer-events:none}' +
      '.pac-lab b{display:block;font-size:12px;font-weight:800;color:var(--pac-c)}' +
      '.pac-dim .pac-lab:not(.pac-on){opacity:.3!important}';
    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]; }); }

    // A wedge from angle a0 to a1 (radians, 0 = twelve o'clock), radius r.
    function wedge(a0, a1, r) {
      if (r <= 0.01) return 'M0 0';
      var x0 = Math.sin(a0) * r, y0 = -Math.cos(a0) * r, x1 = Math.sin(a1) * r, y1 = -Math.cos(a1) * r;
      return 'M0 0L' + x0.toFixed(2) + ' ' + y0.toFixed(2) + 'A' + r.toFixed(2) + ' ' + r.toFixed(2) + ' 0 ' + (a1 - a0 > Math.PI ? 1 : 0) + ' 1 ' + x1.toFixed(2) + ' ' + y1.toFixed(2) + 'Z';
    }

    function setup(el) {
      var d = el.dataset;
      var segs = Array.prototype.slice.call(el.children).map(function (s, i) {
        return { label: s.textContent.trim(), value: +s.getAttribute('data-value') || 0, color: s.getAttribute('data-color') || PALETTE[i % PALETTE.length] };
      });
      var n = segs.length || 1;
      var max = +d.max || Math.max.apply(null, segs.map(function (s) { return s.value; })) || 1;
      var area = d.scale !== 'radius';
      var pre = d.prefix || '', suf = d.suffix || '';
      var gap = Math.min(0.04, Math.PI / n / 6);
      segs.forEach(function (s, i) {
        var f = Math.max(0, Math.min(1, s.value / max));
        s.r = R * (area ? Math.sqrt(f) : f);
        s.a0 = (i / n) * Math.PI * 2 + gap; s.a1 = ((i + 1) / n) * Math.PI * 2 - gap;
      });

      el.classList.add('pac');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Polar area chart. ' + segs.map(function (s) { return s.label + ': ' + pre + s.value.toLocaleString() + suf; }).join(', '));
      var svg = '<svg viewBox="' + -BOX + ' ' + -BOX + ' ' + BOX * 2 + ' ' + BOX * 2 + '">';
      [0.25, 0.5, 0.75, 1].forEach(function (f) {
        svg += '<circle r="' + R * (area ? Math.sqrt(f) : f) + '" fill="none" stroke="currentColor" stroke-opacity="' + (f === 1 ? 0.18 : 0.09) + '" vector-effect="non-scaling-stroke"/>';
      });
      segs.forEach(function (s, i) {
        var a = (i / n) * Math.PI * 2;
        svg += '<line x2="' + (Math.sin(a) * R).toFixed(2) + '" y2="' + (-Math.cos(a) * R).toFixed(2) + '" stroke="currentColor" stroke-opacity=".09" vector-effect="non-scaling-stroke"/>';
      });
      svg += segs.map(function (s) { return '<path class="pac-seg" d="M0 0" fill="' + esc(s.color) + '" fill-opacity=".86"/>'; }).join('') + '</svg>';
      var labels = d.labels === 'false' ? '' : segs.map(function (s) {
        var a = (s.a0 + s.a1) / 2, lr = R + 7;
        var x = 50 + (Math.sin(a) * lr / (BOX * 2)) * 100, y = 50 - (Math.cos(a) * lr / (BOX * 2)) * 100;
        return '<span class="pac-lab" style="left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%;--pac-c:' + esc(s.color) + ';transform:translate(' + (-50 + Math.sin(a) * 50).toFixed(1) + '%,' + (-50 - Math.cos(a) * 50).toFixed(1) + '%)">' + esc(s.label) + '<b>' + esc(pre + s.value.toLocaleString() + suf) + '</b></span>';
      }).join('');
      el.innerHTML = '<div class="pac-wrap" aria-hidden="true">' + svg + labels + '</div>';

      var c = { el: el, segs: segs, k: segs.map(function () { return 0; }) };
      c.wrap = el.querySelector('.pac-wrap');
      c.paths = Array.prototype.slice.call(el.querySelectorAll('.pac-seg'));
      c.labs = Array.prototype.slice.call(el.querySelectorAll('.pac-lab'));
      // Pick a segment out on hover or tap.
      c.paths.forEach(function (p, i) {
        function on() {
          c.wrap.classList.add('pac-dim');
          c.paths.concat(c.labs).forEach(function (x) { x.classList.remove('pac-on'); });
          p.classList.add('pac-on');
          if (c.labs[i]) c.labs[i].classList.add('pac-on');
        }
        p.addEventListener('pointerenter', on);
        p.addEventListener('pointerdown', on);
        p.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') c.wrap.classList.remove('pac-dim'); });
      });
      document.addEventListener('pointerdown', function (e) { if (!c.wrap.contains(e.target)) c.wrap.classList.remove('pac-dim'); });
      el._pac = c;
      size(c);
      if (window.ResizeObserver) new ResizeObserver(function () { size(c); }).observe(el);
      return c;
    }

    // Keep it square, as big as the box allows.
    function size(c) {
      var s = Math.max(60, Math.min(c.el.clientWidth, c.el.clientHeight));
      c.wrap.style.width = c.wrap.style.height = s + 'px';
    }

    function render(c, i) { c.paths[i].setAttribute('d', wedge(c.segs[i].a0, c.segs[i].a1, c.segs[i].r * c.k[i])); }

    function finish(c) {
      c.segs.forEach(function (s, i) { c.k[i] = 1; render(c, i); if (c.labs[i]) c.labs[i].style.opacity = 1; });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var tl = anime.createTimeline();
      c.segs.forEach(function (s, i) {
        var st = { k: 0 };
        c.k[i] = 0; render(c, i);
        tl.add(st, {
          k: 1, duration: duration, ease: 'outBack(1.5)',
          onUpdate: function () { c.k[i] = Math.max(0, st.k); render(c, i); }
        }, i * stagger);
        if (c.labs[i]) tl.add(c.labs[i], { opacity: [0, 1], duration: 350 }, i * stagger + duration * 0.4);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            c.wrap.classList.remove('pac-dim');
            anime.animate(c.labs, { opacity: 0, duration: 250 });
            var st = { k: 1 };
            anime.animate(st, {
              k: 0, duration: 450, ease: 'in(2)',
              onUpdate: function () { c.segs.forEach(function (s, i) { c.k[i] = st.k; render(c, i); }); },
              onComplete: function () { setTimeout(function () { play(c); }, 200); }
            });
          }, 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._pac);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-polar-area-chart]').forEach(function (el) {
        if (el._pac) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-polar-area-chart style="height: 280px">
  <span data-value="42">Mon</span>
  <span data-value="28">Tue</span>
  <span data-value="35">Wed</span>
  <span data-value="50">Thu</span>
</div>