← Charts

Radar Chart Grow

A spider chart whose web fades in ring by ring before the data shape grows out from the centre, each point springing out to its value in turn. Add a second set of values and it grows a comparison shape on top.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-radar-chart-grow to an element with a height, with data-axes (the names round the edge) and data-values (one per axis).

Options

Add any of these attributes to the same element.

data-compareA second set of values, grown as a second shape just after the first.
data-namesNames for the two shapes, e.g. This year,Last year, shown as a legend.
data-maxThe value at the outer ring. Default 100.
data-ringsHow many rings in the web. Default 4.
data-color / data-compare-colorShape colours. Default #a855f7 and #ec4899.
data-show-valuesfalse drops the numbers under the axis names (they’re left off anyway with two shapes).
data-durationTime for each point to spring out. Default 1100 ms.
data-loopFold back into the centre and grow again.

The chart stays square, as big as its box allows, with room round the edge for the names.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Radar Chart Grow: a spider chart whose web fades in ring by ring, then
    // the data shape grows out from the centre, each point springing out to
    // its value in turn, with the points popping in at the tips.
    // Usage:
    //   <div data-radar-chart-grow data-axes="Speed,Power,Range,Comfort,Safety"
    //        data-values="80,65,90,55,75" style="height:280px"></div>
    // Optional: data-compare="60,80,50,70,65" (a second shape grown after the
    // first), data-names="This year,Last year" (names for the two shapes, for
    // the legend and screen readers), data-max (outer ring, default 100),
    // data-rings (default 4), data-color (default #a855f7), data-compare-color
    // (default #ec4899), data-show-values="false" (axis names only),
    // data-duration (ms for each point, default 1100), data-loop (fold back
    // into the centre and grow again).
    var DEFAULTS = { max: 100, rings: 4, color: '#a855f7', compare: '#ec4899', duration: 1100, hold: 2600 };
    var R = 36;

    var css =
      '.rcg{position:relative;display:grid;place-items:center;container-type:size}' +
      '.rcg-box{position:relative;width:min(100cqw,100cqh);height:min(100cqw,100cqh)}' +
      '.rcg-box svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.rcg-ring{transform-box:view-box;transform-origin:50% 50%}' +
      '.rcg-label{position:absolute;font-size:12px;font-weight:600;line-height:1.15;white-space:nowrap;text-align:center}' +
      '.rcg-label small{display:block;font-size:11px;font-weight:500;opacity:.6;font-variant-numeric:tabular-nums}' +
      '.rcg-legend{position:absolute;left:0;bottom:0;display:flex;flex-direction:column;gap:2px;font-size:12px;font-weight:600;text-align:left}' +
      '.rcg-legend span{display:inline-flex;align-items:center;gap:6px}' +
      '.rcg-legend i{width:10px;height:10px;border-radius:3px;background:var(--rcg-c)}';
    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 arr(l) { return Array.prototype.slice.call(l); }
    function nums(s) { return s ? s.split(',').map(Number) : null; }
    function angle(i, n) { return -Math.PI / 2 + (i / n) * Math.PI * 2; }

    function polygon(n, radius) {
      var pts = [];
      for (var i = 0; i < n; i++) pts.push((Math.cos(angle(i, n)) * radius).toFixed(2) + ',' + (Math.sin(angle(i, n)) * radius).toFixed(2));
      return pts.join(' ');
    }

    function setup(el) {
      var d = el.dataset;
      var axes = (d.axes || '').split(',');
      var n = axes.length;
      var max = +d.max || DEFAULTS.max;
      var rings = +d.rings || DEFAULTS.rings;
      var names = d.names ? d.names.split(',') : [];
      var sets = [nums(d.values), nums(d.compare)].filter(Boolean);
      var colors = [d.color || DEFAULTS.color, d.compareColor || DEFAULTS.compare];
      var showValues = d.showValues !== 'false' && sets.length === 1;

      el.classList.add('rcg');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Radar chart. ' + sets.map(function (vals, s) {
        return (names[s] ? names[s] + ': ' : '') + axes.map(function (a, i) { return a + ' ' + vals[i]; }).join(', ');
      }).join('. '));

      var svg = '<svg viewBox="-50 -50 100 100">';
      for (var r = rings; r >= 1; r--) {
        svg += '<polygon class="rcg-ring" points="' + polygon(n, (R * r) / rings) + '" fill="currentColor" fill-opacity="' + (r % 2 ? 0.035 : 0.06) + '" stroke="currentColor" stroke-opacity=".14" stroke-width=".35"/>';
      }
      for (var i = 0; i < n; i++) {
        svg += '<line class="rcg-spoke" x1="0" y1="0" x2="' + (Math.cos(angle(i, n)) * R).toFixed(2) + '" y2="' + (Math.sin(angle(i, n)) * R).toFixed(2) + '" stroke="currentColor" stroke-opacity=".16" stroke-width=".35"/>';
      }
      sets.forEach(function (vals, s) {
        svg += '<polygon class="rcg-shape" points="' + polygon(n, 0) + '" fill="' + colors[s] + '" fill-opacity=".22" stroke="' + colors[s] + '" stroke-width="1.1" stroke-linejoin="round"/>';
      });
      sets.forEach(function (vals, s) {
        for (var i = 0; i < n; i++) svg += '<circle class="rcg-dot" data-s="' + s + '" r="1.5" fill="#fff" stroke="' + colors[s] + '" stroke-width=".9"/>';
      });
      svg += '</svg>';

      var labels = axes.map(function (a, i) {
        var cx = Math.cos(angle(i, n)), cy = Math.sin(angle(i, n));
        var x = 50 + cx * (R + 5), y = 50 + cy * (R + 5);
        // Push each label away from the chart, whichever side it's on.
        return '<span class="rcg-label" style="left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%;transform:translate(' +
          (-50 + cx * 50).toFixed(1) + '%,' + (-50 + cy * 50).toFixed(1) + '%)">' + esc(a) +
          (showValues ? '<small>' + sets[0][i] + '</small>' : '') + '</span>';
      }).join('');
      var legend = sets.length > 1 && names.length ? '<div class="rcg-legend">' + sets.map(function (v, s) {
        return '<span style="--rcg-c:' + colors[s] + '"><i></i>' + esc(names[s] || '') + '</span>';
      }).join('') + '</div>' : '';
      el.innerHTML = '<div class="rcg-box" aria-hidden="true">' + svg + labels + '</div>' + legend;

      var c = {
        el: el, n: n, max: max, sets: sets,
        rings: el.querySelectorAll('.rcg-ring'),
        spokes: el.querySelectorAll('.rcg-spoke'),
        labels: el.querySelectorAll('.rcg-label'),
        shapes: el.querySelectorAll('.rcg-shape'),
        dots: sets.map(function (v, s) { return el.querySelectorAll('.rcg-dot[data-s="' + s + '"]'); }),
        // Each point's current distance from the centre, as a share of its value.
        k: sets.map(function () { var a = []; for (var i = 0; i < n; i++) a.push({ k: 0 }); return a; })
      };
      el._rcg = c;
      return c;
    }

    function redraw(c, s) {
      var vals = c.sets[s], pts = [];
      for (var i = 0; i < c.n; i++) {
        var r = Math.max(0, (Math.min(vals[i], c.max) / c.max) * R * c.k[s][i].k);
        var x = Math.cos(angle(i, c.n)) * r, y = Math.sin(angle(i, c.n)) * r;
        pts.push(x.toFixed(2) + ',' + y.toFixed(2));
        c.dots[s][i].setAttribute('cx', x.toFixed(2));
        c.dots[s][i].setAttribute('cy', y.toFixed(2));
      }
      c.shapes[s].setAttribute('points', pts.join(' '));
    }

    function setAll(c, k) {
      c.sets.forEach(function (v, s) {
        c.k[s].forEach(function (o) { o.k = k; });
        redraw(c, s);
      });
    }

    function play(c, first) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var tl = anime.createTimeline();
      var start = 0;
      if (first) {
        tl.add(c.rings, { scale: [0.2, 1], opacity: [0, 1], duration: 700, delay: anime.stagger(70, { reversed: true }), ease: 'out(3)' }, 0);
        tl.add(c.spokes, { opacity: [0, 1], duration: 500, delay: anime.stagger(40), ease: 'out(2)' }, 150);
        tl.add(c.labels, { opacity: [0, 1], duration: 500, delay: anime.stagger(50) }, 300);
        start = 500;
      }
      c.sets.forEach(function (v, s) {
        var at = start + s * (duration * 0.55);
        tl.add(c.dots[s], { opacity: [0, 1], duration: 200, delay: anime.stagger(70) }, at);
        tl.add(c.k[s], {
          k: [0, 1], duration: duration, delay: anime.stagger(70), ease: 'outElastic(1, .65)',
          onUpdate: function () { redraw(c, s); }
        }, at);
      });

      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var all = [];
            c.sets.forEach(function (v, s) { all = all.concat(c.k[s]); });
            anime.animate(all, {
              k: 0, duration: 550, ease: 'in(2)', delay: anime.stagger(25),
              onUpdate: function () { c.sets.forEach(function (v, s) { redraw(c, s); }); },
              onComplete: function () { play(c, false); }
            });
            c.dots.forEach(function (dots) { anime.animate(dots, { opacity: 0, duration: 300, delay: 300 }); });
          }, 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._rcg, true);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-radar-chart-grow]').forEach(function (el) {
        if (el._rcg) return;
        var c = setup(el);
        if (reduced()) { setAll(c, 1); return; }
        setAll(c, 0);
        anime.utils.set([].concat(arr(c.rings), arr(c.spokes), arr(c.labels)), { opacity: 0 });
        c.dots.forEach(function (dots) { anime.utils.set(dots, { opacity: 0 }); });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-radar-chart-grow
     data-axes="Speed,Power,Range,Comfort,Safety"
     data-values="82,64,90,58,76"
     style="height: 300px"></div>