← Charts

Radial Bar Chart

Bars bent into rings round a centre. Each one sweeps clockwise from twelve o’clock along its pale track to its value, outer ring first, while its figure counts up in the label beside where it starts.

MonTueWedThuFriSat

How to use it

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

Options

Add any of these attributes to the same element.

data-maxValue of a full sweep. Default the largest value.
data-sweepDegrees a full bar turns through. Default 270.
data-colorsComma-separated colours, one per ring. Default purple, pink, blue…
data-colorOn a ring: its colour.
data-prefix / data-suffixText around the figures, e.g. %.
data-durationTime for each bar to sweep. Default 1100 ms.
data-staggerGap between rings. Default 120 ms.
data-loopUnwind and sweep again every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Radial Bar Chart: bars bent into rings round a centre. Each one sweeps
    // clockwise from twelve o'clock along its pale track to its value, outer
    // ring first, while its figure counts up in the label beside its start.
    // Usage:
    //   <div data-radial-bar-chart style="height:260px">
    //     <span data-value="82">Move</span>
    //     <span data-value="64">Exercise</span>
    //     <span data-value="45">Stand</span>
    //   </div>
    // Each child is one ring, outermost first: its text is the label.
    // Optional: data-max (value of a full sweep, default the largest value),
    // data-sweep (degrees a full bar turns through, default 270), data-colors
    // (comma-separated, one per ring, default purple, pink, blue…),
    // data-color on a ring, data-prefix / data-suffix (around the figures),
    // data-duration (ms for each bar to sweep, default 1100), data-stagger
    // (gap between rings, default 120), data-loop (unwind and sweep again).
    var DEFAULTS = { duration: 1100, stagger: 120, hold: 2600, sweep: 270 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.rbc{position:relative;font-variant-numeric:tabular-nums}' +
      '.rbc svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.rbc-track{fill:none;stroke:currentColor;stroke-opacity:.08}' +
      '.rbc-bar{fill:none;stroke-linecap:round}' +
      '.rbc-lab{fill:currentColor;text-anchor:end}' +
      '.rbc-lab tspan{fill-opacity:.65}' +
      '.rbc-lab .rbc-num{font-weight:700;fill-opacity:1}';
    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; }

    // An arc of radius r from twelve o'clock, turning clockwise through deg.
    function arc(cx, cy, r, deg) {
      if (deg <= 0.05) return '';
      deg = Math.min(deg, 359.99);
      var a = (deg - 90) * Math.PI / 180;
      var x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
      return 'M' + cx.toFixed(2) + ',' + (cy - r).toFixed(2) + 'A' + r.toFixed(2) + ',' + r.toFixed(2) + ' 0 ' + (deg > 180 ? 1 : 0) + ' 1 ' + x.toFixed(2) + ',' + y.toFixed(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 (ch) {
        var v = parseFloat(ch.getAttribute('data-value'));
        if (isNaN(v) || v < 0) return;
        items.push({ label: ch.textContent.trim(), v: v, color: ch.getAttribute('data-color') || colors[items.length % colors.length] });
      });
      var max = +d.max || Math.max.apply(null, items.map(function (it) { return it.v; })) || 1;
      var dec = items.some(function (it) { return it.v % 1; }) ? 1 : 0;
      var pre = d.prefix || '', suf = d.suffix || '';
      var c = { el: el, items: items, max: max, sweep: Math.min(359, +d.sweep || DEFAULTS.sweep), k: items.map(function () { return 0; }) };
      c.fmt = function (v) { return pre + v.toFixed(dec) + suf; };
      el.classList.add('rbc');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Radial bar chart. ' + items.map(function (it) { return it.label + ': ' + c.fmt(it.v); }).join(', ') + '.');
      el._rbc = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var W = c.el.clientWidth, H = c.el.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var n = c.items.length;
      var R = Math.min(W, H) / 2 - 2;
      var inner = R * 0.28;
      var band = (R - inner) / n;
      var thick = Math.max(4, band * 0.68);
      var font = Math.max(10, Math.min(14, band * 0.62));
      // Shift right a little when the box is wide so the labels have room.
      var cx = W / 2 + Math.min(R * 0.35, Math.max(0, (W - 2 * R) / 2) * 0.6), cy = H / 2;
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      c.items.forEach(function (it, i) {
        var r = R - band * (i + 0.5);
        it.r = r;
        mk('path', { class: 'rbc-track', d: arc(cx, cy, r, c.sweep), 'stroke-width': thick, 'stroke-linecap': 'round' }, svg);
        it.bar = mk('path', { class: 'rbc-bar', stroke: it.color, 'stroke-width': thick }, svg);
        it.lab = mk('text', { class: 'rbc-lab', x: cx - thick * 0.5 - 6, y: cy - r, dy: '.35em', 'font-size': font.toFixed(1) }, svg);
        var nm = mk('tspan', {}, it.lab);
        nm.textContent = it.label + ' ';
        it.num = mk('tspan', { class: 'rbc-num', fill: it.color }, it.lab);
      });
      c.cx = cx; c.cy = cy;
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      render(c);
    }

    function render(c) {
      c.items.forEach(function (it, i) {
        if (!it.bar) return;
        var k = c.k[i], e = 1 - Math.pow(1 - clamp01(k), 4);
        it.bar.setAttribute('d', arc(c.cx, c.cy, it.r, c.sweep * Math.min(1, it.v / c.max) * e));
        it.num.textContent = c.fmt(it.v * e);
      });
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      c.k = c.items.map(function () { return 0; });
      render(c);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      c.items.forEach(function (it, i) {
        var s = { k: 0 };
        tl.add(s, { k: 1, duration: dur, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, i * stagger);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 650, ease: 'inOut(2)',
              onUpdate: function () { c.k = c.k.map(function () { return o.k; }); 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._rbc);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._rbc;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-radial-bar-chart]').forEach(function (el) {
        if (el._rbc) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { c.k = c.k.map(function () { return 1; }); render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-radial-bar-chart data-max="100" data-suffix="%" style="height: 260px">
  <span data-value="92">Mon</span>
  <span data-value="78">Tue</span>
  <span data-value="64">Wed</span>
</div>