HTML 7 lines
<div class="chart">
<div class="scene">
<div class="stack3d"></div>
</div>
<output></output>
<div class="legend"></div>
</div>
CSS 245 lines
.chart {
display: grid;
justify-items: center;
gap: 8px;
font-family: system-ui, sans-serif;
}
.scene {
perspective: 800px;
padding: 36px 40px 34px;
pointer-events: none; /* the chart is turned: only the faces take the pointer */
}
/* 4 columns × 24px + 3 gaps × 16px = 144px */
.stack3d {
position: relative;
width: 144px;
height: 100px; /* 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 {
position: absolute;
left: -12px;
top: 74px;
width: 168px;
height: 52px;
border: 1px solid rgb(139 108 255 / 0.45);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, rgb(139 108 255 / 0.22) 0 1px, transparent 1px 14px),
repeating-linear-gradient(rgb(139 108 255 / 0.22) 0 1px, transparent 1px 13px),
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: -12px; /* the box holds the numbers too: text spilling out of a 3D layer gets cut */
left: -12px;
width: 194px;
height: 112px;
transform: translateZ(-26px);
}
.wall b {
position: absolute;
right: 26px;
bottom: calc(var(--t) * 100px);
left: 0;
height: 1px;
background: rgb(236 238 251 / 0.24);
}
.wall span {
position: absolute;
top: -6px;
left: 100%;
padding-left: 6px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
}
.cols {
position: absolute;
inset: 0;
display: flex;
gap: 16px;
transform-style: preserve-3d;
}
.col {
position: relative;
flex: none;
width: 24px;
height: 100%;
transform-style: preserve-3d;
}
/* the quarter, standing in front of the column */
.col span {
position: absolute;
top: calc(100% + 8px);
left: -6px;
width: 36px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
text-align: center;
transform: translateZ(12px);
}
/* 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: 24px;
height: 100%;
border: 0.6px 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 8px color-mix(in srgb, var(--c) 30%, transparent),
0 0 10px 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 20px 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) * -100px)) translateZ(12px) 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) * -100px)) rotateY(90deg) translateZ(12px) scaleY(var(--v));
}
/* the lid: a 24px square laid flat on the segment's top. Only the highest segment shows it */
.seg i:nth-child(3) {
height: 24px;
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)) * 100px)) rotateX(90deg) translateZ(12px);
}
.seg.is-top i:nth-child(3) {
opacity: 1;
pointer-events: auto;
}
.seg.is-off i {
pointer-events: none; /* collapsed: nothing to point at */
}
/* One tooltip for the whole chart: JS moves it to the pointed-at segment (--tx, --ty) and it
glides there. It floats 40px 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 */
.tip {
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--c);
border-radius: 6px;
background: #141830;
box-shadow:
3px -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/12px system-ui, sans-serif;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 30px)) translate(-50%, -100%) translateZ(40px);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
}
.tip.is-on {
opacity: 1;
}
output {
color: #949bc0;
font-size: 12px;
}
/* the legend is the switches: one real button per product */
.legend {
display: flex;
gap: 6px;
}
.legend button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px 4px 8px;
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--c) 16%, transparent);
color: #eceefb;
font: 700 12px system-ui, sans-serif;
cursor: pointer;
}
/* the swatch: filled when on, a hollow ring when off */
.legend button::before {
content: '';
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--c);
box-shadow: inset 0 0 0 1.5px var(--c);
}
.legend button[aria-pressed='false'] {
border-color: rgb(255 255 255 / 0.14);
background: transparent;
color: #949bc0;
}
.legend button[aria-pressed='false']::before {
background: transparent;
}
JS 154 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 (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, and one tooltip
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>' +
'<b class="tip" data-p="0" aria-hidden="true"></b>';
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 = chart.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 + 'px');
tipEl.style.setProperty('--ty', (1 - topOf) * H + 'px');
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();
Sass source 292 lines
// Stacked bars drawn from JSON. JS writes two numbers per segment, both fractions of the scale:
// --base (the sum of the segments below it) and --v (its own height). Each segment's front face is a
// full-height box lifted by --base and squashed with scaleY(--v) from its bottom, and so is its
// side; the lid rides on top. Switching a product off sets its --v to 0 and
// lowers every --base above it: one transition on transform, no layout.
@use 'sass:list';
$w: 24px; // column width, and depth
$gap: 16px;
$h: 100px; // the top of the scale
$floor: 52px; // the floor's depth, front to back
$n: 4;
$span: $w * $n + $gap * ($n - 1);
// no overshoot: a face shrinking to 0 would turn inside out, and collapse and slide must stay glued
$ease: cubic-bezier(0.22, 1, 0.36, 1);
$time: 0.7s;
// one colour per product, bottom to top
$colors: var(--accent), var(--accent-2), var(--hot);
.d-stackbars {
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
// The chart is turned, so its left columns sit behind the plane of the flat boxes around it
// (this one and the view), which would catch the pointer first: only the faces and the dock
// take it.
pointer-events: none;
@for $k from 1 through list.length($colors) {
[data-p='#{$k - 1}'] {
--c: #{list.nth($colors, $k)};
}
}
&__view {
display: grid;
place-items: center;
min-height: 0;
pointer-events: none;
}
&__chart {
position: relative;
width: $span;
height: $h;
margin-top: 16px; // room for a tooltip over the tallest column
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-28deg);
}
// the floor: a grid laid flat along the bottom of the columns
&__floor {
position: absolute;
left: -12px;
top: $h - $floor * 0.5;
width: $span + 24px;
height: $floor;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 14px),
repeating-linear-gradient(color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 13px),
color-mix(in srgb, var(--accent) 8%, transparent);
transform: rotateX(90deg);
}
// the scale: lines at 0, half and the top, on a wall along the back edge of the floor, numbers
// at the right end, which reaches out past the last column
// (The wall's box also holds the numbers, 12px above the top line and 26px past its end: text
// that spilled out of the box of a 3D layer got cut off.)
&__wall {
position: absolute;
top: -12px;
left: -12px;
width: $span + 24px + 26px;
height: $h + 12px;
transform: translateZ($floor * -0.5);
b {
position: absolute;
right: 26px;
bottom: calc(var(--t) * #{$h});
left: 0;
height: 1px;
background: color-mix(in srgb, var(--text) 24%, transparent);
}
span {
position: absolute;
top: -6px;
left: 100%;
padding-left: 6px;
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
}
}
&__cols {
position: absolute;
inset: 0;
display: flex;
gap: $gap;
transform-style: preserve-3d;
}
&__col {
position: relative;
flex: none;
width: $w;
height: 100%;
transform-style: preserve-3d;
// the quarter, standing in front of the column
span {
position: absolute;
top: calc(100% + 8px);
left: -6px;
width: $w + 12px;
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
text-align: center;
transform: translateZ($w * 0.5);
}
}
// A segment only groups its three faces and carries --base / --v (display: contents: it draws
// no box, so nothing flat of it sits in the turned chart to catch the pointer). The faces are the
// hit targets; JS finds the segment with closest().
&__seg {
display: contents;
// see-through glass faces with a fine bright edge
i {
position: absolute;
top: 0;
left: 0;
width: $w;
height: 100%;
// a fine edge: under 1px shows as a hairline on sharp screens (a standard one draws 1px)
border: 0.6px 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 8px color-mix(in srgb, var(--c) 30%, transparent),
0 0 10px color-mix(in srgb, var(--c) 22%, transparent);
outline: none;
pointer-events: auto;
cursor: pointer;
transform-origin: bottom center;
transition:
transform $time $ease calc(var(--i) * 50ms),
opacity 0.25s;
// the pointed-at segment fills in and glows (.is-active, set with the tooltip); faded in with
// opacity, so it is smooth
&::after {
content: '';
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--c) 50%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--c) 60%, transparent);
opacity: 0;
transition: opacity 0.25s;
}
}
&.is-active i::after {
opacity: 1;
}
// front: lifted by the segments below, stepped out to the front, then squashed to its value
// (the lift comes first, so it is not squashed)
i:nth-child(1) {
transform: translateY(calc(var(--base) * #{-$h})) translateZ($w * 0.5) scaleY(var(--v));
}
// right side, darker
i:nth-child(2) {
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
transform: translateY(calc(var(--base) * #{-$h})) rotateY(90deg) translateZ($w * 0.5) scaleY(var(--v));
}
// the lid: a $w square laid flat on the segment's top. Only the highest segment shows it (and
// only then takes the pointer).
i:nth-child(3) {
height: $w;
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)) * #{$h})) rotateX(90deg) translateZ($w * 0.5);
}
&.is-top i:nth-child(3) {
opacity: 1;
pointer-events: auto;
}
// collapsed: nothing to point at
&.is-off i {
pointer-events: none;
}
}
// One tooltip for the chart: JS moves it to the pointed-at segment (--tx, --ty) and CSS glides
// it there, so it slides from segment to segment with its text changing instead of blinking. It
// lives in the chart's turned plane, 40px towards you (each column to the right stands nearer
// than the one before). It stays flat (opacity on a preserve-3d box flattens it and flickers):
// its thickness is a hard edge offset up and to the right, the way the columns' depth runs.
&__tip {
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--c);
border-radius: 6px;
background: var(--surface-solid);
box-shadow:
3px -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/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 30px)) translate(-50%, -100%) translateZ(40px);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
&.is-on {
opacity: 1;
}
}
&__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;
}
}
// the legend is the switches: one real button per product
&__legend {
display: flex;
gap: 6px;
button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px 4px 8px;
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--c) 16%, transparent);
color: var(--text);
font: inherit;
font-weight: 700;
cursor: pointer;
// the swatch: filled when on, a hollow ring when off
&::before {
content: '';
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--c);
box-shadow: inset 0 0 0 1.5px var(--c);
}
&[aria-pressed='false'] {
border-color: var(--border-strong);
background: transparent;
color: var(--muted);
&::before {
background: transparent;
}
}
}
}
}