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

Stacked 3D bars from JSON in CSS + JavaScript

Quarterly revenue split by product, each quarter a column of stacked glass cuboids. The legend is a row of toggles: switch a product off and its segments shrink to nothing while the ones above slide down. JS only writes where each segment starts and how tall it is.

  • 340 lines of CSS
  • 12 lines of HTML
  • 153 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The data is plain JSON: quarters, and per product one value per quarter. JS turns each value into two numbers, both fractions of the scale's top: --base, the sum of the segments below it, and --v, its own height.
  2. A segment is a full-height front and side squashed from the bottom: translateY(--base × −100 units) … scaleY(--v). The move comes first, so it is not squashed. Only transform changes, so a new layout is a transition with no layout work.
  3. Switch a product off and JS sets its --v to 0 and recomputes every --base above it. Collapse and slide use the same duration and easing, so the stack stays glued while it moves (an easing that overshoots would turn a shrinking face inside out, so this one does not). Only the highest segment (.is-top) shows its lid.
  4. The faces are see-through glass: the colour mixed with transparent, a hairline edge and a soft glow. The pointed-at segment fills in and glows: a ::after layer on each face whose opacity fades in.
  5. The chart is turned, so parts of it sit behind the flat boxes around it: pointer-events: none on everything, auto only on the faces.
  6. The tooltip is not in the chart's 3D context. Inside it, the browser splits every plane against the columns' faces, and slivers of the neighbouring bar showed through across its text. It sits in a second layer with the chart's own box and turn, laid over it: the same place, painted after the chart, so no bar can cross it.
  7. There is one tooltip for the whole chart. JS moves it to the pointed-at segment with --tx / --ty and a transition glides it there while its text changes; it hides 150ms after the pointer leaves, so crossing a gap doesn't flicker. A tap does the same on a touch screen. Names go in with textContent, never as HTML.
  8. Every length is a multiple of one base unit, --u, and JS writes the tooltip's place as plain numbers in the chart's own units, which CSS multiplies by it. A length written in px from JS would stay the same size while the chart scaled around it, and the tooltip would drift off its segment. The caption and the legend are in plain vmin: the control zone is the same object, at the same size, in every model.

Key techniques

  • JSON → --base + --v per segment (fractions of the scale)
  • translateY(--base) then scaleY(--v): stacking with transform only
  • one shared tooltip gliding on --tx / --ty
  • tooltip on a twin 3D layer over the chart
  • legend = <button aria-pressed> toggles

Copy-paste code

HTML 12 lines

<div class="chart">
  <div class="view">
    <div class="scene">
      <div class="stack3d"></div>
      <div class="float"><b class="tip" data-p="0" aria-hidden="true"></b></div>
    </div>
  </div>
  <div class="controls">
    <output class="caption"></output>
    <div class="row legend"></div>
  </div>
</div>

CSS 340 lines

.chart {
  /* 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.33vmin;
  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;
}

.scene {
  position: relative; /* the tooltip's layer is laid over the chart */
  perspective: calc(800 * var(--u));
  /* the floor and the quarter labels hang below the columns: more room under them than over
     them keeps the drawing centred in the model box and clear of the caption */
  padding: calc(22 * var(--u)) calc(40 * var(--u)) calc(50 * var(--u));
  pointer-events: none; /* the chart is turned: only the faces take the pointer */
}

/* 4 columns × 24 + 3 gaps × 16 = 144 units */
.stack3d {
  position: relative;
  width: calc(144 * var(--u));
  height: calc(100 * var(--u)); /* the top of the scale */
  transform-style: preserve-3d;
  transform: rotateX(-18deg) rotateY(-28deg);
}

/* the floor: a grid laid flat along the bottom of the columns */
.floor {
  box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
  position: absolute;
  left: calc(-12 * var(--u));
  top: calc(74 * var(--u));
  width: calc(168 * var(--u));
  height: calc(52 * var(--u));
  border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.45);
  border-radius: calc(6 * var(--u));
  background:
    repeating-linear-gradient(90deg, rgb(139 108 255 / 0.22) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(14 * var(--u))),
    repeating-linear-gradient(rgb(139 108 255 / 0.22) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(13 * var(--u))),
    rgb(139 108 255 / 0.08);
  transform: rotateX(90deg);
}

/* the scale: lines at 0, half and the top, along the back of the floor; the numbers at the
   right end, which reaches out past the last column */
.wall {
  position: absolute;
  top: calc(-12 * var(--u)); /* the box holds the numbers too: text spilling out of a 3D layer gets cut */
  left: calc(-12 * var(--u));
  width: calc(194 * var(--u));
  height: calc(112 * var(--u));
  transform: translateZ(calc(-26 * var(--u)));
}

.wall b {
  position: absolute;
  right: calc(26 * var(--u));
  bottom: calc(var(--t) * calc(100 * var(--u)));
  left: 0;
  height: calc(1 * var(--u));
  background: rgb(236 238 251 / 0.24);
}

.wall span {
  position: absolute;
  top: calc(-6 * var(--u));
  left: 100%;
  padding-left: calc(6 * var(--u));
  color: inherit; /* the stage's own ink, softened with opacity: a fixed pale lavender was 2.3:1 on the light stage */
  opacity: 0.72;
  font: 700 calc(12 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
}

.cols {
  position: absolute;
  inset: 0;
  display: flex;
  gap: calc(16 * var(--u));
  transform-style: preserve-3d;
}

.col {
  position: relative;
  flex: none;
  width: calc(24 * var(--u));
  height: 100%;
  transform-style: preserve-3d;
}

/* the quarter, standing in front of the column */
.col span {
  position: absolute;
  top: calc(100% + calc(8 * var(--u)));
  left: calc(-6 * var(--u));
  width: calc(36 * var(--u));
  color: inherit; /* the stage's ink, softened: see .wall span */
  opacity: 0.72;
  font: 700 calc(12 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
  text-align: center;
  transform: translateZ(calc(12 * var(--u)));
}

/* one colour per product */
[data-p='0'] { --c: #8b6cff; }
[data-p='1'] { --c: #2ee6d6; }
[data-p='2'] { --c: #ff4d9d; }

/* A segment only groups its three faces and carries --base / --v: it draws no box itself */
.seg {
  --base: 0; /* JS writes both: fractions of the scale */
  --v: 0;
  display: contents;
}

/* see-through glass faces with a fine bright edge */
.seg i {
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: calc(24 * var(--u));
  height: 100%;
  border: calc(0.6 * var(--u)) solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
  background: color-mix(in srgb, var(--c) 58%, transparent);
  box-shadow:
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, var(--c) 30%, transparent),
    0 0 calc(10 * var(--u)) color-mix(in srgb, var(--c) 22%, transparent);
  outline: none;
  pointer-events: auto;
  cursor: pointer;
  transform-origin: bottom center;
  /* each column 50ms after the one before; no overshoot, so a face never turns inside out */
  transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) calc(var(--i) * 50ms), opacity 0.25s;
}

/* the pointed-at segment fills in and glows: a layer on each face that fades in */
.seg i::after {
  content: '';
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--c) 50%, transparent);
  box-shadow: 0 0 calc(20 * var(--u)) color-mix(in srgb, var(--c) 60%, transparent);
  opacity: 0;
  transition: opacity 0.25s;
}

.seg.is-active i::after {
  opacity: 1;
}

/* front: lift by the segments below, step out to the front, then squash to its value */
.seg i:nth-child(1) {
  transform: translateY(calc(var(--base) * calc(-100 * var(--u)))) translateZ(calc(12 * var(--u))) scaleY(var(--v));
}

/* right side, darker */
.seg i:nth-child(2) {
  background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
  transform: translateY(calc(var(--base) * calc(-100 * var(--u)))) rotateY(90deg) translateZ(calc(12 * var(--u))) scaleY(var(--v));
}

/* the lid: a 24-unit square laid flat on the segment's top. Only the highest segment shows it */
.seg i:nth-child(3) {
  height: calc(24 * var(--u));
  background: color-mix(in srgb, var(--c) 80%, transparent);
  opacity: 0;
  pointer-events: none;
  transform-origin: center;
  transform: translateY(calc((1 - var(--base) - var(--v)) * calc(100 * var(--u)))) rotateX(90deg) translateZ(calc(12 * var(--u)));
}

.seg.is-top i:nth-child(3) {
  opacity: 1;
  pointer-events: auto;
}

.seg.is-off i {
  pointer-events: none; /* collapsed: nothing to point at */
}

/* The tooltip's own layer: the chart's box and turn again, laid exactly over it. It is a second
   3D context, painted after the chart, so no face of a column can ever cross the tooltip. In the
   chart's own context, the faces' planes split it, and slivers of the bar beside it showed
   through across the text */
.float {
  position: absolute;
  top: calc(22 * var(--u)); /* the scene's padding: the same box as .stack3d */
  left: calc(40 * var(--u));
  width: calc(144 * var(--u));
  height: calc(100 * var(--u));
  transform-style: preserve-3d;
  transform: rotateX(-18deg) rotateY(-28deg);
}

/* One tooltip for the whole chart: JS moves it to the pointed-at segment (--tx, --ty) and it
   glides there. It floats 40 units towards you (the columns to the right stand nearer); its
   thickness is a flat edge offset up and to the right, the way the columns' depth runs */
/* JS writes --tx / --ty as plain numbers in the chart's own units, and CSS multiplies them by
   --u: a length written in px from JS would stay put while the chart scaled around it */
.tip {
  position: absolute;
  top: 0;
  left: 0;
  padding: calc(3 * var(--u)) calc(7 * var(--u));
  border: calc(1 * var(--u)) solid var(--c);
  border-radius: calc(6 * var(--u));
  background: #141830;
  box-shadow:
    calc(3 * var(--u)) calc(-3 * var(--u)) 0 color-mix(in srgb, var(--c) 55%, #05060c),
    0 0 calc(14 * var(--u)) color-mix(in srgb, var(--c) 40%, transparent);
  color: #eceefb;
  font: 700 calc(12 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translate(calc(var(--tx, 0) * var(--u)), calc((var(--ty, 0) - 30) * var(--u))) translate(-50%, -100%) translateZ(calc(40 * var(--u)));
  transition:
    transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 0.2s;
}

.tip.is-on {
  opacity: 1;
}

/* 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. The caption is on its own line above
   the row, and its line box never changes height, so a new summary cannot move the chart. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  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.

   Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image
   was painted straight over that colour. The one control that was already selected was the one
   control that did nothing when you pointed at it. check-motion named it on six of these charts
   ('hover button#1 "24h": changes nothing on screen').

   Darker, not lighter: 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 legend is the switches: one real button per product, in the product's colour when on.
   Its word stays the stage's ink, on a tint light enough to keep it readable on both stages */
.controls button {
  display: inline-flex;
  align-items: center;
  gap: 1.5vmin;
}

.legend 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;
}

/* and so does a switch that is on: same fault, by weight rather than by paint -- this rule and the
   hover above are of equal specificity, and this one comes later, so it simply won */
.legend button[aria-pressed='true']:hover {
  background: color-mix(in srgb, var(--c) 34%, transparent);
}

/* the swatch: filled when on, a hollow ring when off */
.legend button::before {
  content: '';
  width: 2.6vmin;
  height: 2.6vmin;
  border-radius: 50%;
  background: var(--c);
  box-shadow: inset 0 0 0 0.5vmin var(--c);
}

/* off: the block's plain pill, a little faded */
.legend button[aria-pressed='false'] {
  opacity: 0.75;
}

.legend button[aria-pressed='false']::before {
  background: transparent;
}

JS 153 lines

// The data, as an API would send it back
const REVENUE = {
  "prefix": "$",
  "suffix": "k",
  "quarters": ["Q1", "Q2", "Q3", "Q4"],
  "products": [
    {
      "name": "Pro",
      "values": [42, 48, 55, 63]
    },
    {
      "name": "Team",
      "values": [30, 38, 41, 50]
    },
    {
      "name": "Starter",
      "values": [18, 22, 20, 27]
    }
  ]
};

const { quarters, products } = REVENUE;
const chart = document.querySelector('.stack3d');
const out = document.querySelector('.chart output');
const legend = document.querySelector('.legend');
const TICKS = [0, 0.5, 1]; // the scale's lines, as fractions of its top
const W = 24, PITCH = 40, H = 100; // a column's width, width + gap, and the scale's height, in the chart's own units (as in the CSS)
const on = products.map(() => true); // which products are shown

// money as most dashboards write it: the sign in front, the k right after the number ($38k)
const money = (v) => REVENUE.prefix + v + REVENUE.suffix;
const tipText = (q, p) => `${quarters[q]} · ${products[p].name} · ${money(products[p].values[q])}`;

// Build the chart once: the floor, the scale, per quarter a column with one segment (front, side,
// lid) per product. The tooltip is in the HTML, on a layer of its own over the chart
chart.innerHTML =
  '<div class="floor"></div>' +
  '<div class="wall">' + TICKS.map((t) => `<b style="--t:${t}"><span></span></b>`).join('') + '</div>' +
  '<div class="cols">' +
  quarters.map((_, q) => `<div class="col" style="--i:${q}">` +
    products.map((_, p) => `<div class="seg" data-p="${p}"><i role="img" tabindex="0"></i><i></i><i></i></div>`).join('') +
    '<span></span></div>').join('') +
  '</div>';
legend.innerHTML = products.map((_, p) => `<button type="button" data-p="${p}"></button>`).join('');

const cols = [...chart.querySelectorAll('.col')];
const segs = cols.map((c) => [...c.querySelectorAll('.seg')]);
const ticks = chart.querySelectorAll('.wall span');
const tipEl = document.querySelector('.tip');
const buttons = [...legend.querySelectorAll('button')];

// names and labels go in as text, never as HTML: data from an API is not trusted markup
cols.forEach((c, q) => (c.querySelector('span').textContent = quarters[q]));
buttons.forEach((b, p) => (b.textContent = products[p].name));
segs.forEach((col, q) => col.forEach((s, p) => s.firstElementChild.setAttribute('aria-label', tipText(q, p))));

let active = null; // [quarter, product] the tooltip is on
let hideTimer;

// Lay out the shown products: each segment gets --base (the sum below it) and --v (its value),
// both as fractions of the scale's top. CSS does the rest.
function render() {
  const totals = quarters.map((_, q) => products.reduce((s, p, k) => s + (on[k] ? p.values[q] : 0), 0));
  const most = Math.max(...totals);
  const top = Math.max(20, Math.ceil(most / 20) * 20); // a tidy top for the scale
  segs.forEach((col, q) => {
    let base = 0;
    let highest = null;
    col.forEach((s, p) => {
      const v = on[p] ? products[p].values[q] / top : 0;
      s.style.setProperty('--base', base);
      s.style.setProperty('--v', v);
      s.classList.remove('is-top');
      s.classList.toggle('is-off', !on[p]);
      s.firstElementChild.tabIndex = on[p] ? 0 : -1; // a switched-off segment leaves the tab order
      s.firstElementChild.toggleAttribute('aria-hidden', !on[p]);
      if (v > 0) highest = s;
      base += v;
    });
    if (highest) highest.classList.add('is-top'); // only the highest segment shows its lid
  });
  ticks.forEach((t, k) => (t.textContent = Math.round(TICKS[k] * top)));
  const names = products.filter((_, k) => on[k]).map((p) => p.name);
  const sum = totals.reduce((a, b) => a + b, 0);
  const best = quarters[totals.indexOf(most)];
  out.textContent = !names.length
    ? 'No product shown · switch one on'
    : `${names.length === products.length ? 'All products' : names.join(' + ')} · ${money(sum)} · best ${best}, ${money(most)}`;
  buttons.forEach((b, k) => b.setAttribute('aria-pressed', on[k]));
  // the tooltip follows its segment to its new place, or goes if the segment did
  if (active) on[active[1]] ? place(...active) : hideNow();
}

// One tooltip for the chart: it glides to the segment (--tx, --ty) and its text changes on the way
function place(q, p) {
  clearTimeout(hideTimer);
  const seg = segs[q][p];
  const wasOn = tipEl.classList.contains('is-on');
  if (!wasOn) tipEl.style.transition = 'none'; // from hidden: appear in place, don't fly in
  tipEl.textContent = tipText(q, p);
  tipEl.dataset.p = p; // its colour
  const topOf = parseFloat(seg.style.getPropertyValue('--base')) + parseFloat(seg.style.getPropertyValue('--v'));
  tipEl.style.setProperty('--tx', q * PITCH + W / 2); // plain numbers: CSS multiplies them by --u
  tipEl.style.setProperty('--ty', (1 - topOf) * H);
  if (!wasOn) {
    void tipEl.offsetWidth; // apply the new place before the transition comes back
    tipEl.style.transition = '';
  }
  tipEl.classList.add('is-on');
  segs.flat().forEach((s) => s.classList.toggle('is-active', s === seg)); // it fills in and glows
  active = [q, p];
}
function hideNow() {
  clearTimeout(hideTimer);
  tipEl.classList.remove('is-on');
  segs.flat().forEach((s) => s.classList.remove('is-active'));
  active = null;
}
// a short grace period, so crossing the gap between two segments does not flicker it
function hide() {
  clearTimeout(hideTimer);
  hideTimer = setTimeout(hideNow, 150);
}

const segOf = (e) => {
  const s = e.target.closest?.('.seg');
  return s && !s.classList.contains('is-off') ? s : null;
};
const pointAt = (s) => place(cols.indexOf(s.parentElement), +s.dataset.p);
chart.addEventListener('pointerover', (e) => segOf(e) && pointAt(segOf(e)));
chart.addEventListener('focusin', (e) => segOf(e) && pointAt(segOf(e)));
const leave = (e) => {
  if (!e.relatedTarget?.closest?.('.seg')) hide();
};
chart.addEventListener('pointerout', leave);
chart.addEventListener('focusout', leave);
// a finger has no hover: a tap on a segment shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
  if (e.pointerType === 'mouse') return;
  const s = segOf(e);
  if (s) pointAt(s);
  else hide();
});

// the legend is the switches
legend.addEventListener('click', (e) => {
  const b = e.target.closest('button');
  if (!b) return;
  on[b.dataset.p] = !on[b.dataset.p];
  render();
});

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