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

Donut chart from JSON in CSS + JavaScript

Seats by plan as a thick donut lying back in 3D, drawn from a JSON object: JS turns the values into one conic-gradient, a stack of layers gives it thickness, the total stands up in the hole and a legend sits beside it. Switch a plan off below and the ring re-divides; hover or focus the chart and it lifts toward you.

  • 240 lines of CSS
  • 21 lines of HTML
  • 75 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The chart is one conic-gradient. JS walks the segments that are on, turns each value into a share of the total and writes a pair of stops per segment (colour A% B%, so the edges are hard); the whole gradient goes into one custom property, --pie, and every layer paints background: var(--pie). Nothing is drawn per segment.
  2. The thickness is a stack of eight round layers at translateZ(--i × 2 units) inside a disc that lies back with rotateX(42deg). The lower ones paint a dark tint over the same gradient, so the rim reads as the side of each colour; only the top layer is full strength. A real extrusion would need a wall per segment; a stack needs nothing to change when a segment goes.
  3. The hole is a mask on every layer, a radial-gradient that is clear to the hole’s radius and solid past it. It cuts the far side’s inner wall out too, so you see the inside of the ring through the hole, which is what makes it a donut rather than a disc with a badge.
  4. The total stands up in the hole: rotateX(-42deg) is the disc’s own tilt in reverse, so the plate faces you while its foot stays on the disc, and it rides the stack (translateZ to the top layer) before the turn so it stands on the surface, not in it.
  5. Hover and focus lift the disc toward you, translateZ on a wrapper that itself never takes the pointer (pointer-events: none): the still box round it does, so the hover cannot flicker at the edges. The buttons are real <button aria-pressed>; a click only flips which segments are on and JS writes the gradient again. Every length is a multiple of one base unit, --u, and the control zone under it is in plain vmin, the same object in every model.

Key techniques

  • JSON → conic-gradient stops in one custom property
  • thickness: a stack of layers at translateZ(--i × 2 units)
  • the hole: a radial-gradient mask on every layer
  • the total stands up: the disc’s rotateX undone

Copy-paste code

HTML 21 lines

<div class="donut">
  <div class="view">
    <div class="scene">
      <div class="chart3d" tabindex="0" role="img" aria-label="Donut chart">
        <div class="lift">
          <div class="disc">
            <i class="layer" style="--i:0"></i><i class="layer" style="--i:1"></i>
            <i class="layer" style="--i:2"></i><i class="layer" style="--i:3"></i>
            <i class="layer" style="--i:4"></i><i class="layer" style="--i:5"></i>
            <i class="layer" style="--i:6"></i><i class="layer" style="--i:7"></i>
            <b class="total" aria-hidden="true"><span></span><em></em></b>
          </div>
        </div>
      </div>
      <div class="legend" aria-hidden="true"></div>
    </div>
  </div>
  <div class="controls">
    <div class="row toggles"></div>
  </div>
</div>

CSS 240 lines

.donut {
  /* one base unit: every length in the chart is a multiple of it, so it is the same share of a
     card, the editor, a full screen and a recording canvas. The control zone under it is in
     plain vmin, because it is the same object in every model. */
  --u: 0.36vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin; /* the band's gap between the model and the control zone */
  font-family: Inter, system-ui, sans-serif;
}

/* the model box: the same height in every model that has controls */
.view {
  display: grid;
  place-items: center;
  height: 50vmin;
}

/* the disc and the legend side by side. The scene must fit the 50vmin box: taller than it, the
   grid row grows past the box from its top and nothing is centred any more */
.scene {
  display: flex;
  align-items: center;
  gap: calc(14 * var(--u));
  perspective: calc(800 * var(--u));
}

/* the still wrapper: it takes the hover and the focus and never moves, so the hover never
   flickers at its edges. Its box is the disc's drawn height (the tilted ellipse and the rim),
   not the disc's square, so the scene fits the model box and the drawn stack is centred */
.chart3d {
  position: relative;
  width: calc(160 * var(--u));
  height: calc(130 * var(--u));
  outline: none;
  transform-style: preserve-3d;
  cursor: pointer;
}

/* what moves on hover: lifted toward you, transform only */
.lift {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
  pointer-events: none;
}

.chart3d:hover .lift,
.chart3d:focus-visible .lift {
  transform: translateZ(calc(28 * var(--u)));
}

/* the disc, a 160-unit square centred in the wrapper, lies back like a plate on a table */
.disc {
  position: absolute;
  top: 50%;
  left: 0;
  width: calc(160 * var(--u));
  height: calc(160 * var(--u));
  margin-top: calc(-80 * var(--u));
  transform-style: preserve-3d;
  transform: rotateX(42deg);
}

/* the stack: eight round layers, 2 units apart, every one the same gradient with a hole
   masked out. The lower ones carry a dark tint, so the rim reads as the side of each colour */
.layer {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: linear-gradient(rgb(5 6 12 / 0.45), rgb(5 6 12 / 0.45)), var(--pie);
  -webkit-mask: radial-gradient(circle, transparent calc(44 * var(--u)), #000 calc(44.6 * var(--u)));
  mask: radial-gradient(circle, transparent calc(44 * var(--u)), #000 calc(44.6 * var(--u)));
  transform: translateZ(calc(var(--i) * 2 * var(--u)));
}

/* the top layer: full colour, with a hairline of light on its edge */
.layer:nth-child(8) {
  background: var(--pie);
  box-shadow: inset 0 0 0 calc(0.6 * var(--u)) rgb(255 255 255 / 0.25);
}

/* the total, standing in the hole on a plate of its own. rotateX(-42deg) undoes the disc's
   tilt so it faces you; the translateZ before it lifts its foot onto the top layer */
.total {
  position: absolute;
  top: 50%;
  left: 50%;
  box-sizing: border-box;
  padding: calc(3 * var(--u)) calc(8 * var(--u)) calc(4 * var(--u));
  border-radius: calc(6 * var(--u));
  background: #141830;
  box-shadow: 0 0 0 calc(1 * var(--u)) rgb(139 108 255 / 0.55);
  color: #eceefb;
  font: 700 calc(15 * var(--u)) / calc(17 * var(--u)) Inter, system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  text-align: center;
  white-space: nowrap;
  transform-origin: 50% 100%;
  transform: translate(-50%, -100%) translateZ(calc(15 * var(--u))) rotateX(-42deg);
}

.total span {
  display: block;
}

.total em {
  display: block;
  color: #949bc0;
  font-size: calc(9 * var(--u));
  font-style: normal;
  line-height: calc(11 * var(--u));
}

/* the legend beside the disc: the stage's own ink, softened as the caption is, so it reads on
   the dark stage and the light one */
.legend {
  display: grid;
  gap: calc(7 * var(--u));
  opacity: 0.7;
  font: 700 calc(11 * var(--u)) / calc(13 * var(--u)) Inter, system-ui, sans-serif;
  white-space: nowrap;
}

.legend div {
  display: flex;
  align-items: center;
  gap: calc(5 * var(--u));
}

/* the name over the value: two short lines keep the legend narrow beside the disc */
.legend span {
  display: grid;
}

/* the segment's colour as a dot: filled when on, a ring when off */
.legend i {
  box-sizing: border-box;
  flex: none;
  width: calc(8 * var(--u));
  height: calc(8 * var(--u));
  border: calc(2.5 * var(--u)) solid var(--c);
  border-radius: 50%;
  background: var(--c);
}

.legend .is-off i {
  background: transparent;
}

.legend em {
  display: block;
  font-weight: 500;
  font-style: normal;
  font-size: calc(10 * var(--u));
  line-height: calc(12 * var(--u));
  font-variant-numeric: tabular-nums;
}

/* the control zone: the same object, at the same size, in every model that has one — so it is
   written in plain vmin and not in the chart's own unit */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}
.controls .caption { font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif; opacity: 0.7; }
.controls .row { display: flex; gap: 2vmin; }
.controls button,
.controls label {
  height: 8vmin;
  min-width: 8vmin;
  padding: 0 3vmin;
  box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
  border: 0;
  border-radius: 999px;
  /* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
     strength: no colour of the model's, which would vanish on one of the two stages */
  background: rgb(140 150 220 / 0.2);
  color: inherit;
  font: 600 4vmin Inter, system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 0.35s;
}

.controls button:hover,
.controls label:hover {
  background-color: rgb(140 150 220 / 0.34);
}

.controls :focus-visible {
  outline: 0.6vmin solid #6a45f5;
  outline-offset: 0.6vmin;
}

/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  color: #fff;
}

/* the pill already chosen answers the pointer too: darker, since the gradient above is as pale
   as it can be with white text still passing */
.controls [aria-pressed='true']:hover {
  background-image: linear-gradient(135deg, #5730dd, #b31859);
}

/* the segment buttons: real toggles, each with its colour as a dot. Its word stays the stage's
   ink, on a tint of the segment's colour light enough to keep it readable on both stages */
.controls button {
  display: inline-flex;
  align-items: center;
  gap: 1.5vmin;
}

.toggles button::before {
  content: '';
  box-sizing: border-box;
  width: 2.6vmin;
  height: 2.6vmin;
  border: 0.6vmin solid var(--c);
  border-radius: 50%;
}

.toggles button[aria-pressed='true'] {
  background: color-mix(in srgb, var(--c) 22%, transparent);
  box-shadow: inset 0 0 0 0.3vmin color-mix(in srgb, var(--c) 60%, transparent);
  color: inherit;
}

/* a segment that is on answers the pointer in its own colours: deeper tint, same ink */
.toggles button[aria-pressed='true']:hover {
  background: color-mix(in srgb, var(--c) 34%, transparent);
}

.toggles button[aria-pressed='true']::before {
  background: var(--c);
}

JS 75 lines

// The data, as an API would send it back
const SEATS = {
  "unit": "seats",
  "segments": [
    { "name": "Pro", "value": 420 },
    { "name": "Team", "value": 310 },
    { "name": "Edu", "value": 190 },
    { "name": "Free", "value": 320 }
  ]
};

const COLORS = ['#8b6cff', '#2ee6d6', '#ff4d9d', '#ffb547']; // one per segment
const chart = document.querySelector('.chart3d');
const disc = chart.querySelector('.disc');
const totalNum = disc.querySelector('.total span');
const totalUnit = disc.querySelector('.total em');
const legend = document.querySelector('.legend');
const toggles = document.querySelector('.toggles');
const on = SEATS.segments.map(() => true); // which segments are shown
const num = (n) => n.toLocaleString('en-US'); // 1,240

// The legend and the buttons, built once. Names go in as text, never as HTML: an API is not
// trusted markup.
const rows = SEATS.segments.map((s, k) => {
  const row = document.createElement('div');
  row.style.setProperty('--c', COLORS[k]);
  const dot = document.createElement('i');
  const text = document.createElement('span');
  const name = document.createElement('b');
  name.textContent = s.name;
  const value = document.createElement('em');
  text.append(name, value);
  row.append(dot, text);
  legend.append(row);
  return { row, value };
});
const buttons = SEATS.segments.map((s, k) => {
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.textContent = s.name;
  btn.style.setProperty('--c', COLORS[k]);
  toggles.append(btn);
  return btn;
});

// The chart is one conic-gradient: a pair of stops per segment that is on, hard-edged, going
// clockwise from the top. JS writes the whole gradient into --pie; every layer paints it.
function render() {
  const sum = SEATS.segments.reduce((t, s, k) => t + (on[k] ? s.value : 0), 0);
  const stops = [];
  let from = 0;
  SEATS.segments.forEach((s, k) => {
    const share = on[k] && sum ? s.value / sum : 0;
    const to = from + share;
    if (on[k]) stops.push(`${COLORS[k]} ${(from * 100).toFixed(2)}% ${(to * 100).toFixed(2)}%`);
    rows[k].value.textContent = on[k] ? `${num(s.value)} · ${Math.round(share * 100)}%` : 'hidden';
    rows[k].row.classList.toggle('is-off', !on[k]);
    buttons[k].setAttribute('aria-pressed', on[k]);
    from = to;
  });
  // with nothing on, an empty ring in the pill's own tint, so the shape stays
  disc.style.setProperty('--pie', stops.length ? `conic-gradient(from -90deg, ${stops.join(', ')})` : 'conic-gradient(rgb(140 150 220 / 0.3) 0 100%)');
  totalNum.textContent = num(sum);
  totalUnit.textContent = SEATS.unit;
  chart.setAttribute('aria-label', `Donut chart. ${num(sum)} ${SEATS.unit} in all. ` +
    SEATS.segments.map((s, k) => on[k] ? `${s.name} ${num(s.value)}, ${Math.round((s.value / sum) * 100)}%` : `${s.name} hidden`).join('. '));
}

// A button only flips a segment; render writes the gradient again
buttons.forEach((btn, k) => btn.addEventListener('click', () => {
  on[k] = !on[k];
  render();
}));

render();
Standalone snippet · plain CSS, no build step, no dependencies.