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
- Paste the snippet just before
</head>. - Add
data-radar-chart-growto an element with a height, withdata-axes(the names round the edge) anddata-values(one per axis).
Options
Add any of these attributes to the same element.
data-compare | A second set of values, grown as a second shape just after the first. |
data-names | Names for the two shapes, e.g. This year,Last year, shown as a legend. |
data-max | The value at the outer ring. Default 100. |
data-rings | How many rings in the web. Default 4. |
data-color / data-compare-color | Shape colours. Default #a855f7 and #ec4899. |
data-show-values | false drops the numbers under the axis names (they’re left off anyway with two shapes). |
data-duration | Time for each point to spring out. Default 1100 ms. |
data-loop | Fold 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.
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><div data-radar-chart-grow
data-axes="Speed,Power,Range,Comfort,Safety"
data-values="82,64,90,58,76"
style="height: 300px"></div>