CSS + JS Data & tools #controls #hover #data #chart #json #svg

3D radar chart from JSON in CSS + JavaScript

Two series compared on six axes, drawn as SVG from a JSON object, on a plate lying back with each series floating at its own height. Switch a series on or off below; hover or tap the chart and it lies flat, with one tooltip gliding from axis to axis.

  • 249 lines of CSS
  • 14 lines of HTML
  • 231 lines of JS
  • No dependencies
  • MIT licensed
Video 1.5 MB GitHub

How it works

  1. The chart is ordinary SVG. Axis i points at angle i × 360° ÷ n, so a score becomes the point (cos a, sin a) × radius × value ÷ max. Join a series’ points and you have its <polygon>; the same maths at fixed fractions draws the rings of the web.
  2. The 3D is only on containers: the plate lies back with rotateX(50deg) rotateZ(-12deg), and each series is its own layer at translateZ 10 and 22px, so the two see-through shapes float over the web at different heights instead of blending into one plane.
  3. The axis names would be squashed by the tilt, so each one stands up: rotateZ(12deg) rotateX(-50deg) is the plate’s rotation in reverse order, which cancels it and leaves the text facing you.
  4. Hovering the still wrapper sets every rotation to 0: the plate lies flat and the layers sink onto the web so the points meet the rings. The wrapper never moves, so the hover never flickers, and the pointer’s angle from its centre says which axis you point at.
  5. There is one tooltip for the chart. JS writes the axis’ place into --tx / --ty and changes its text; a transform transition makes it glide from axis to axis instead of labels blinking in and out. From hidden it is placed with the transition off, so it does not fly in.
  6. The series buttons are real <button aria-pressed>; switching one only toggles a class that fades its layer with opacity. The series you point at (its button, or the leader on the axis the tooltip shows) fades in a fuller, glowing copy of its polygon. The glow is the polygon drawn again, wide and faint, underneath: no blur filter.

Key techniques

  • JSON → points (cos / sin × value) → <polygon>
  • series layers at translateZ 10 / 22px
  • axis names stand up: the plate’s rotation undone
  • one tooltip that glides: --tx / --ty + transform transition

Copy-paste code

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);
        }
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.