HTML 14 lines
<div class="radar">
<div class="view">
<div class="chart3d" tabindex="0" role="img" aria-label="Radar chart">
<div class="plate">
<div class="base"></div>
<svg class="web" viewBox="0 0 208 164" aria-hidden="true"><path class="rings" /><path class="spokes" /></svg>
<div class="axes"></div>
<b class="tip" aria-hidden="true"><em></em></b>
</div>
</div>
</div>
<output></output>
<div class="toggles"></div>
</div>
CSS 249 lines
.radar {
display: grid;
justify-items: center;
gap: 8px;
font-family: system-ui, sans-serif;
}
.view {
perspective: 800px;
padding: 16px 30px 10px;
pointer-events: none; /* the plate lies back, partly behind this box: only the wrapper takes the pointer */
}
/* the still wrapper: it takes the hover and never moves, so the hover never flickers */
.chart3d {
position: relative;
width: 208px;
height: 164px;
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
}
/* the tilt lives here */
.plate {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateX(50deg) rotateZ(-12deg);
transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.chart3d:hover .plate,
.chart3d:focus-visible .plate,
.chart3d.is-flat .plate {
transform: rotateX(0deg) rotateZ(0deg);
}
.base {
position: absolute;
inset: 0;
border: 1px solid rgb(139 108 255 / 0.5);
border-radius: 14px;
background: linear-gradient(150deg, #2a2560, #141830);
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
}
.plate svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.web {
transform: translateZ(1px);
}
.rings {
fill: none;
stroke: rgb(139 108 255 / 0.45);
stroke-width: 1;
}
.spokes {
fill: none;
stroke: rgb(236 238 251 / 0.16);
stroke-width: 1;
}
/* one SVG layer per series, floating at its own height (--z); --c is its colour */
.series {
transform: translateZ(var(--z));
transition:
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.35s;
}
/* lying flat, the layers sink onto the web so the points meet the rings */
.chart3d:hover .series,
.chart3d:focus-visible .series,
.chart3d.is-flat .series {
transform: translateZ(calc(var(--z) / 8));
}
/* see-through glass with a bright 1px edge */
.series polygon {
fill: color-mix(in srgb, var(--c) 46%, transparent);
stroke: color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
stroke-width: 1;
stroke-linejoin: round;
}
/* the glow: the same polygon, wide and faint, underneath (cheaper than a blur filter) */
.series .glow {
fill: none;
stroke: var(--c);
stroke-width: 8;
stroke-opacity: 0.22;
}
/* the series lit up (its button pointed at, or the leader on the pointed axis): a fuller,
glowing copy that fades in */
.series .lit {
fill: color-mix(in srgb, var(--c) 28%, transparent);
stroke: var(--c);
stroke-width: 14;
stroke-opacity: 0.3;
opacity: 0;
transition: opacity 0.25s;
}
.series.is-active .lit {
opacity: 1;
}
/* a dot at every point: zero-length segments ("M x y h0") with round caps */
.series .dots {
fill: none;
stroke: color-mix(in srgb, var(--c) 60%, #eceefb);
stroke-width: 5;
stroke-linecap: round;
}
/* a series switched off in the dock fades out */
.series.is-off {
opacity: 0;
}
/* the axis names stand up: the plate's rotation, reversed, cancels it */
.axes {
position: absolute;
inset: 0;
transform-style: preserve-3d;
}
.axes span {
position: absolute;
top: var(--y);
left: var(--x);
color: #eceefb;
font: 700 9px/12px system-ui, sans-serif;
white-space: nowrap;
/* --ax: 1 reads rightwards from the anchor, -1 leftwards, 0 is centred on it */
translate: calc((var(--ax) - 1) * 50%) -50%;
transform: translateZ(6px) rotateZ(12deg) rotateX(-50deg);
transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chart3d:hover .axes span,
.chart3d:focus-visible .axes span,
.chart3d.is-flat .axes span {
transform: translateZ(2px) rotateZ(0deg) rotateX(0deg);
}
/* ONE tooltip for the chart: JS sets its place (--tx, --ty), its text and its colour (--c, the
axis' leader); the transform transition makes it glide from axis to axis. Its thickness is
a hard shadow below it, not a 3D box. */
.tip {
--c: #8b6cff;
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--c);
border-radius: 6px;
background: #141830;
box-shadow:
0 3px 0 color-mix(in srgb, var(--c) 55%, #05060c),
0 0 14px color-mix(in srgb, var(--c) 40%, transparent);
color: #eceefb;
font: 700 9px/11px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), var(--ty, 0px)) translate(-50%, -50%) translateZ(8px);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
}
.tip.is-on {
opacity: 1;
}
.tip em {
display: block;
color: #949bc0;
font-size: 8px;
font-style: normal;
line-height: 10px;
}
.tip span {
margin: 0 3px;
color: color-mix(in srgb, var(--c) 75%, #eceefb);
}
/* a series switched off has no value in the tooltip */
.tip span.is-off {
display: none;
}
output {
color: #949bc0;
font-size: 12px;
}
.toggles {
display: flex;
gap: 6px;
}
.toggles button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: 999px;
background: transparent;
color: #949bc0;
font: 700 12px system-ui, sans-serif;
cursor: pointer;
}
/* the series' colour as a dot: hollow when off, filled when on */
.toggles button::before {
content: '';
box-sizing: border-box;
width: 8px;
height: 8px;
border: 2px solid var(--c);
border-radius: 50%;
}
.toggles button[aria-pressed='true'] {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 18%, transparent);
color: #eceefb;
}
.toggles button[aria-pressed='true']::before {
background: var(--c);
}
JS 231 lines
// The data, as an API would send it back
const RADAR = {
"max": 10,
"axes": ["Speed", "Design", "Docs", "Tests", "Support", "Price"],
"series": [
{
"name": "Ours",
"values": [8, 9, 6, 7, 8, 5]
},
{
"name": "Theirs",
"values": [6, 5, 8, 5, 4, 8]
}
]
};
const COLORS = ['#2ee6d6', '#ff4d9d']; // one per series
const W = 208, H = 164, CX = W / 2, CY = H / 2; // the plate; the SVG viewBox is the same, so 1 unit = 1px
const R = 56; // the web's outer ring
const n = RADAR.axes.length;
const score = (v) => `${v}/${RADAR.max}`; // the way a dashboard writes it: 8/10
// Axis i points straight up for i = 0 and goes round clockwise
const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / n;
// the point at distance r from the centre along axis i
const at = (i, r) => [CX + Math.cos(angle(i)) * r, CY + Math.sin(angle(i)) * r];
const fix = ([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`;
// 1 for an axis pointing right, -1 left, 0 straight up or down
const side = (i) => (Math.abs(Math.cos(angle(i))) < 0.3 ? 0 : Math.sign(Math.cos(angle(i))));
// The web: a ring every fifth of the scale, and a spoke per axis
document.querySelector('.rings').setAttribute('d',
[0.2, 0.4, 0.6, 0.8, 1].map((f) => 'M' + RADAR.axes.map((_, i) => fix(at(i, R * f))).join(' L') + 'Z').join(' '));
document.querySelector('.spokes').setAttribute('d',
RADAR.axes.map((_, i) => `M${CX} ${CY} L${fix(at(i, R))}`).join(' '));
const chart = document.querySelector('.chart3d');
const plate = chart.querySelector('.plate');
const axes = plate.querySelector('.axes');
const tip = plate.querySelector('.tip');
const SVG = 'http://www.w3.org/2000/svg';
const svgEl = (tag, attrs) => {
const el = document.createElementNS(SVG, tag);
for (const k in attrs) el.setAttribute(k, attrs[k]);
return el;
};
// One SVG layer per series: a glow, the lit-up copy (hidden until its button is pointed at),
// the glass polygon and its dots
const layers = RADAR.series.map((s, k) => {
const pts = s.values.map((v, i) => at(i, (R * v) / RADAR.max));
const poly = pts.map((p) => fix(p).replace(' ', ',')).join(' ');
const svg = svgEl('svg', { class: 'series', viewBox: `0 0 ${W} ${H}`, 'aria-hidden': 'true' });
svg.style.cssText = `--c:${COLORS[k]}; --z:${10 + k * 12}px`; // each series 12px above the last
svg.append(
svgEl('polygon', { class: 'glow', points: poly }),
svgEl('polygon', { class: 'lit', points: poly }),
svgEl('polygon', { points: poly }),
svgEl('path', { class: 'dots', d: pts.map((p) => `M${fix(p)}h0`).join('') }),
);
plate.insertBefore(svg, axes);
return svg;
});
// The axis names, just outside the web: the side ones start 14px out and read outwards
// (--ax: 1 or -1), the top and bottom ones sit 18px out, centred (--ax: 0).
// Text from the data always goes in with textContent, never as HTML: an API is not trusted markup.
RADAR.axes.forEach((a, i) => {
const [x, y] = at(i, R + (side(i) ? 14 : 18));
const label = document.createElement('span');
label.textContent = a;
label.style.cssText = `--x:${x}px; --y:${y}px; --ax:${side(i)}`;
axes.append(label);
});
// The tooltip: the axis' name, then one value per series in its colour
const tipName = tip.querySelector('em');
const tipValues = RADAR.series.map((_, k) => {
const span = document.createElement('span');
span.style.setProperty('--c', COLORS[k]);
tip.append(span);
return span;
});
chart.setAttribute('aria-label', 'Radar chart. ' +
RADAR.series.map((s) => `${s.name}: ${s.values.map((v, i) => `${RADAR.axes[i]} ${score(v)}`).join(', ')}`).join('. '));
// The series buttons: real buttons with aria-pressed
const toggles = document.querySelector('.toggles');
const buttons = RADAR.series.map((s, k) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = s.name;
btn.style.setProperty('--c', COLORS[k]);
btn.setAttribute('aria-pressed', 'true');
toggles.append(btn);
return btn;
});
const shown = () => buttons.map((b) => b.getAttribute('aria-pressed') === 'true');
// Which shown series scores highest on axis i (-1 for a tie, or when none is shown)
function leader(i, on) {
let best = -1, tie = false;
RADAR.series.forEach((s, k) => {
if (!on[k]) return;
const top = best < 0 ? -Infinity : RADAR.series[best].values[i];
if (s.values[i] > top) { best = k; tie = false; }
else if (s.values[i] === top) tie = true;
});
return tie ? -1 : best;
}
// The dock's line: who leads where, for the series that are switched on
const out = document.querySelector('.radar output');
function summary(on) {
const list = RADAR.series.filter((_, k) => on[k]);
if (list.length === 0) return 'Nothing shown · press a name to bring it back';
if (list.length === 1) {
const { name, values } = list[0];
const hi = Math.max(...values), lo = Math.min(...values);
return `${name} · best ${RADAR.axes[values.indexOf(hi)]} ${score(hi)}, lowest ${RADAR.axes[values.indexOf(lo)]} ${score(lo)}`;
}
const [a, b] = list;
const wins = a.values.filter((v, i) => v > b.values[i]).length;
const losses = a.values.filter((v, i) => v < b.values[i]).length;
const ties = n - wins - losses;
return `${a.name} leads on ${wins} of ${n} · ${b.name} on ${losses}` + (ties ? ` · ${ties} tied` : '');
}
out.textContent = summary(shown());
// ONE tooltip for the chart. JS writes the axis' place into --tx / --ty and changes the text;
// the CSS transition makes it glide from axis to axis.
let active = -1;
let hideTimer = 0;
let lead = -1; // the series leading on the pointed axis
let pointed = -1; // the series whose button is pointed at or focused
// One series at a time fills in and glows more: the pointed button's, else the axis' leader.
// CSS fades its brighter copy in and out with opacity.
function relight() {
const k = pointed >= 0 ? pointed : lead;
layers.forEach((l, j) => l.classList.toggle('is-active', j === k));
}
// (refresh: only new text for the same axis, e.g. after a toggle; a pending hide still runs)
function place(i, refresh = false) {
if (!refresh) clearTimeout(hideTimer);
const wasOn = tip.classList.contains('is-on');
// from hidden it appears in place, not flying in from where it was last
if (!wasOn) tip.style.transition = 'none';
const [x, y] = at(i, R + (side(i) ? 26 : 14)); // over the axis' name
tip.style.setProperty('--tx', `${x}px`);
tip.style.setProperty('--ty', `${y}px`);
lead = leader(i, shown());
tip.style.setProperty('--c', lead < 0 ? '#8b6cff' : COLORS[lead]);
relight();
tipName.textContent = RADAR.axes[i];
tipValues.forEach((el, k) => (el.textContent = score(RADAR.series[k].values[i])));
if (!wasOn) {
void tip.offsetWidth; // apply the new place before the transition comes back
tip.style.transition = '';
}
tip.classList.add('is-on');
active = i;
}
// a short grace period, so leaving and coming straight back does not blink it
function hide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
tip.classList.remove('is-on');
active = -1;
lead = -1;
relight();
}, 150);
}
// the axis nearest the pointer's direction from the centre of the (still) wrapper
function axisAt(e) {
const r = chart.getBoundingClientRect();
const a = Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2));
return ((Math.round((a + Math.PI / 2) / ((2 * Math.PI) / n)) % n) + n) % n;
}
chart.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
const i = axisAt(e);
if (i !== active || !tip.classList.contains('is-on')) place(i);
});
chart.addEventListener('pointerleave', (e) => {
if (e.pointerType === 'mouse') hide();
});
// a finger has no hover: a tap lays the chart flat and points at the tapped axis;
// tapping the same axis again stands it back up
chart.addEventListener('pointerup', (e) => {
if (e.pointerType === 'mouse') return;
const i = axisAt(e);
if (chart.classList.contains('is-flat') && i === active) {
chart.classList.remove('is-flat');
hide();
} else {
chart.classList.add('is-flat');
place(i);
}
});
// keyboard: focus shows the first axis, the arrow keys walk round
chart.addEventListener('focus', () => place(active < 0 ? 0 : active));
chart.addEventListener('blur', hide);
chart.addEventListener('keydown', (e) => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step) return;
e.preventDefault();
place((((active < 0 ? 0 : active) + step) % n + n) % n);
});
// A button only flips classes; CSS fades the layer (opacity). Pointing at one lights its series up.
buttons.forEach((btn, k) => {
btn.addEventListener('click', () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', on);
layers[k].classList.toggle('is-off', !on);
tipValues[k].classList.toggle('is-off', !on);
out.textContent = summary(shown());
if (tip.classList.contains('is-on')) place(active, true); // the leader may have changed
});
const light = (on) => () => {
pointed = on ? k : -1;
relight();
};
btn.addEventListener('pointerenter', light(true));
btn.addEventListener('pointerleave', light(false));
btn.addEventListener('focus', light(true));
btn.addEventListener('blur', light(false));
});
Sass source 281 lines
// A radar chart from JSON. The chart is plain SVG (JS turns each score into a point on its axis
// and each series into a polygon); the 3D is all on the containers: the plate lies back, each
// series is a layer at its own height (--z) over the web, and the axis names stand up to face
// you. Hovering the (still) wrapper lays the plate flat, and one tooltip glides between the
// axes. The dock's buttons fade a series in and out; pointing at one lights its series up.
$w: 208px; // the plate: the same numbers as W and H in radar.ts (its SVG viewBox)
$h: 164px;
$tilt-x: 50deg;
$tilt-z: -12deg;
$ease: cubic-bezier(0.2, 0.8, 0.2, 1);
$time: 0.7s;
.d-radar {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
width: 100%;
height: 100%;
padding: 10px 14px 14px; // the control dock: the same place in every model with controls
transform-style: preserve-3d;
// The plate lies back, so its far half sits behind the plane of the flat boxes around it
// (this one and the view), which would catch the pointer first: only the still wrapper and
// the dock take it.
pointer-events: none;
&__view {
display: grid;
place-items: center;
min-height: 0;
transform-style: preserve-3d;
pointer-events: none;
}
// the hover target: it never moves, so the hover never flickers
&__chart {
position: relative;
width: $w;
height: $h;
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
}
// the tilt lives here
&__plate {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateX($tilt-x) rotateZ($tilt-z);
transition: transform $time $ease;
pointer-events: none;
}
&__chart:hover &__plate,
&__chart:focus-visible &__plate,
&__chart.is-flat &__plate {
transform: rotateX(0deg) rotateZ(0deg);
}
&__base {
position: absolute;
inset: 0;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 14px;
background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 24%, var(--surface-solid)), var(--surface-solid));
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
}
svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
&__web {
transform: translateZ(1px);
}
&__rings {
fill: none;
stroke: color-mix(in srgb, var(--accent) 45%, transparent);
stroke-width: 1;
}
&__spokes {
fill: none;
stroke: color-mix(in srgb, var(--text) 16%, transparent);
stroke-width: 1;
}
// one SVG layer per series, floating at its own height (--z); --c is its colour
&__series {
transform: translateZ(var(--z));
transition:
transform $time $ease,
opacity 0.35s;
// see-through glass with a bright 1px edge
polygon {
fill: color-mix(in srgb, var(--c) 46%, transparent);
stroke: color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
stroke-width: 1;
stroke-linejoin: round;
}
// a series switched off in the dock fades out
&.is-off {
opacity: 0;
}
}
// lying flat, the layers sink onto the web so the points meet the rings
&__chart:hover &__series,
&__chart:focus-visible &__series,
&__chart.is-flat &__series {
transform: translateZ(calc(var(--z) / 8));
}
// the glow: the same polygon, wide and faint, underneath (cheaper than a blur filter)
& &__glow {
fill: none;
stroke: var(--c);
stroke-width: 8;
stroke-opacity: 0.22;
}
// the series lit up (its button pointed at, or the leader on the pointed axis): a fuller,
// glowing copy that fades in
& &__lit {
fill: color-mix(in srgb, var(--c) 28%, transparent);
stroke: var(--c);
stroke-width: 14;
stroke-opacity: 0.3;
opacity: 0;
transition: opacity 0.25s;
}
&__series.is-active &__lit {
opacity: 1;
}
// a dot at every point: zero-length segments ("M x y h0") with round caps
&__dots {
fill: none;
stroke: color-mix(in srgb, var(--c) 60%, var(--text));
stroke-width: 5;
stroke-linecap: round;
}
// the axis names stand up: the plate's rotation in reverse order cancels it
&__axes {
position: absolute;
inset: 0;
transform-style: preserve-3d;
span {
position: absolute;
top: var(--y);
left: var(--x);
color: var(--text);
font: 700 9px/12px system-ui, sans-serif;
white-space: nowrap;
// --ax: 1 reads rightwards from the anchor, −1 leftwards, 0 is centred on it
translate: calc((var(--ax) - 1) * 50%) -50%;
transform: translateZ(6px) rotateZ(-$tilt-z) rotateX(-$tilt-x);
transition: transform $time $ease;
}
}
&__chart:hover &__axes span,
&__chart:focus-visible &__axes span,
&__chart.is-flat &__axes span {
transform: translateZ(2px) rotateZ(0deg) rotateX(0deg);
}
// ONE tooltip for the chart: JS sets its place (--tx, --ty), its text and its colour (--c, the
// axis' leader); the transform transition makes it glide from axis to axis. It is a flat
// element (opacity on a preserve-3d one would flatten it), and its thickness is a hard shadow
// below it.
&__tip {
--c: var(--accent);
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--c);
border-radius: 6px;
background: var(--surface-solid);
box-shadow:
0 3px 0 color-mix(in srgb, var(--c) 55%, #05060c),
0 0 14px color-mix(in srgb, var(--c) 40%, transparent);
color: var(--text);
font: 700 9px/11px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), var(--ty, 0px)) translate(-50%, -50%) translateZ(8px);
transition:
transform 0.35s $ease,
opacity 0.2s;
&.is-on {
opacity: 1;
}
em {
display: block;
color: var(--muted);
font-size: 8px;
font-style: normal;
line-height: 10px;
}
span {
margin: 0 3px;
color: color-mix(in srgb, var(--c) 75%, var(--text));
// a series switched off has no value in the tooltip
&.is-off {
display: none;
}
}
}
&__dock {
display: grid;
justify-items: center;
gap: 6px;
font-size: 12px;
pointer-events: auto;
output {
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
&__toggles {
display: flex;
gap: 6px;
button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: color-mix(in srgb, var(--bg) 70%, transparent);
color: var(--muted);
font: inherit;
font-weight: 700;
cursor: pointer;
// the series' colour as a dot: hollow when off, filled when on
&::before {
content: '';
box-sizing: border-box;
width: 8px;
height: 8px;
border: 2px solid var(--c);
border-radius: 50%;
}
&[aria-pressed='true'] {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 18%, transparent);
color: var(--text);
&::before {
background: var(--c);
}
}
}
}
}