HTML 15 lines
<div class="chart">
<div class="view">
<div class="scene">
<div class="bars3d"></div>
</div>
</div>
<div class="controls">
<output class="caption"></output>
<div class="row">
<button type="button" data-year="2024">2024</button>
<button type="button" data-year="2025">2025</button>
<button type="button" data-year="2026">2026</button>
</div>
</div>
</div>
CSS 282 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.27vmin;
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 {
perspective: calc(800 * var(--u));
/* the scale's numbers and the tooltip reach out past the right of the bars: the extra room on
the right is what centres what is drawn, not the bars' box. The room below is more than
above, so the bars sit high in the box: the tooltip's room over the tallest bar is empty at
rest, and with the bars in the middle the drawn stack sat 5vmin low */
padding: calc(14 * var(--u)) calc(72 * var(--u)) calc(57 * var(--u)) calc(40 * var(--u));
pointer-events: none; /* the chart is turned: only the bars take the pointer */
}
/* 6 bars × 19 + 5 gaps × 10 = 164 units */
.bars3d {
position: relative;
width: calc(164 * var(--u));
height: calc(100 * var(--u)); /* the top of the scale */
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-28deg);
}
/* the floor: a neon grid laid flat along the bottom */
.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(188 * 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.24) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(17 * var(--u))),
repeating-linear-gradient(rgb(139 108 255 / 0.24) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(17 * var(--u))),
rgb(139 108 255 / 0.1);
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 bar */
.wall {
position: absolute;
top: calc(-10 * var(--u)); /* 10 units of room above the scale, so the top number is not clipped */
left: calc(-12 * var(--u));
width: calc(188 * var(--u));
height: calc(110 * var(--u));
transform: translateZ(calc(-26 * var(--u)));
}
.wall b {
position: absolute;
right: 0;
bottom: calc(var(--t) * 100 * var(--u));
left: 0;
height: calc(1 * var(--u));
background: rgb(236 238 251 / 0.26);
}
.wall span {
position: absolute;
top: calc(-6 * var(--u));
left: 100%;
padding-left: calc(6 * var(--u));
/* the stage's own ink, softened with opacity: a fixed pale lavender read on the dark stage and
was 2.2:1 on the light one */
color: inherit;
opacity: 0.72;
font: 700 calc(11 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
}
.bars {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: calc(10 * var(--u));
transform-style: preserve-3d;
}
.bar {
--v: 0; /* JS writes it: value ÷ top of the scale */
--c: color-mix(in srgb, #8b6cff, #2ee6d6 calc(var(--i) * 20%));
position: relative;
flex: none;
width: calc(19 * var(--u));
height: 100%;
transform-style: preserve-3d;
}
.bar.is-peak {
--c: #ff4d9d;
}
.bar {
outline: none;
pointer-events: auto;
cursor: pointer;
}
/* One tooltip for the whole chart: JS moves it to the pointed-at bar (--tx, --ty, plain numbers
in the chart's own units, which CSS multiplies by --u) and it glides there, text changing on
the way. 40 units towards you (the chart is turned, so the bars to the right stand nearer);
its thickness is a flat edge up and to the right, like the bars' depth. It clears the bar by 12
units: the tooltip on the tallest bar is the chart's tallest state, and the drawn stack stays
inside the band there, not only at rest */
.tip {
--c: color-mix(in srgb, #8b6cff, #2ee6d6 calc(var(--i, 0) * 20%));
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(11 * 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) - 12) * 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-peak {
--c: #ff4d9d;
}
.tip.is-on {
opacity: 1;
}
/* see-through glass faces with a bright edge: the neon look */
.bar i {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: calc(19 * var(--u));
height: 100%;
/* a fine edge: under one unit shows as a hairline on sharp screens */
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);
transform-origin: bottom center;
/* past 1: overshoot and settle; each bar 60ms after the one before */
transition: transform 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 60ms);
}
/* the pointed-at bar fills in and glows; faded with opacity, so it is smooth */
.bar 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;
}
.bar.is-active i::after {
opacity: 1;
}
/* front */
.bar i:nth-child(1) {
transform: translateZ(calc(9.5 * var(--u))) scaleY(var(--v));
}
/* right side, darker glass */
.bar i:nth-child(2) {
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
transform: rotateY(90deg) translateZ(calc(9.5 * var(--u))) scaleY(var(--v));
}
/* the lid: a 19-unit square laid flat, riding down with the value */
.bar i:nth-child(3) {
height: calc(19 * var(--u));
background: color-mix(in srgb, var(--c) 80%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--v)) * 100 * var(--u))) rotateX(90deg) translateZ(calc(9.5 * var(--u)));
}
.bar span {
position: absolute;
top: calc(100% + calc(8 * var(--u)));
left: calc(-7 * var(--u));
width: calc(33 * var(--u));
color: inherit; /* the stage's ink, softened: see .wall span */
opacity: 0.72;
font: 700 calc(11 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
text-align: center;
transform: translateZ(calc(9.5 * var(--u)));
}
/* 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's 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;
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 already selected was the one control that
did nothing when you pointed at it, which is what check-motion kept saying:
'hover button#1 "Low": 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);
}
JS 127 lines
// The data, as an API would send it back
const SALES = {
"prefix": "$",
"suffix": "k",
"years": {
"2024": [
{ "label": "Jan", "value": 42 },
{ "label": "Feb", "value": 58 },
{ "label": "Mar", "value": 51 },
{ "label": "Apr", "value": 73 },
{ "label": "May", "value": 66 },
{ "label": "Jun", "value": 88 }
],
"2025": [
{ "label": "Jan", "value": 64 },
{ "label": "Feb", "value": 71 },
{ "label": "Mar", "value": 97 },
{ "label": "Apr", "value": 82 },
{ "label": "May", "value": 109 },
{ "label": "Jun", "value": 94 }
],
"2026": [
{ "label": "Jan", "value": 118 },
{ "label": "Feb", "value": 96 },
{ "label": "Mar", "value": 131 },
{ "label": "Apr", "value": 124 },
{ "label": "May", "value": 142 },
{ "label": "Jun", "value": 157 }
]
}
};
const chart = document.querySelector('.bars3d');
const out = document.querySelector('.chart output');
const buttons = document.querySelectorAll('.controls .row button');
const TICKS = [0, 0.5, 1]; // the scale's lines, as fractions of its top
// Build the chart once from the data: the floor, the scale, and a bar with three faces per value
const first = Object.values(SALES.years)[0];
chart.innerHTML =
'<div class="floor"></div>' +
'<div class="wall">' + TICKS.map((t) => `<b style="--t:${t}"><span></span></b>`).join('') + '</div>' +
'<div class="bars">' + first.map((_, i) => `<div class="bar" style="--i:${i}" tabindex="0"><i></i><i></i><i></i><span></span></div>`).join('') + '</div>' +
'<b class="tip" aria-hidden="true"></b>';
const bars = [...chart.querySelectorAll('.bar')];
const ticks = chart.querySelectorAll('.wall span');
const tipEl = chart.querySelector('.tip');
// labels go in as text, never as HTML: data from an API is not trusted markup
first.forEach((d, i) => (bars[i].querySelector('span').textContent = d.label));
// money as most dashboards write it: the sign in front, the k right after the number ($88k)
const money = (v) => SALES.prefix + v + SALES.suffix;
const W = 19, PITCH = 29, H = 100; // a bar's width, width + gap, and the scale's height, in the chart's own units (as in the CSS)
let shown; // the year on screen: { rows, top, peak }
let active = -1; // the bar the tooltip is on
let hideTimer;
// Show one year: each bar gets --v, its value as a fraction of the scale's top. CSS does the rest.
function show(year) {
const rows = SALES.years[year];
const values = rows.map((r) => r.value);
const top = Math.ceil(Math.max(...values) / 20) * 20; // a tidy top for the scale
const peak = values.indexOf(Math.max(...values));
shown = { rows, top, peak };
rows.forEach((r, i) => {
bars[i].style.setProperty('--v', r.value / top);
bars[i].classList.toggle('is-peak', i === peak);
bars[i].setAttribute('aria-label', `${r.label} · ${money(r.value)}`);
});
ticks.forEach((t, k) => (t.textContent = Math.round(TICKS[k] * top)));
out.textContent = `${year} · peak ${rows[peak].label}, ${money(rows[peak].value)}`;
buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.year === year));
if (active >= 0) place(active); // the tooltip follows its bar to the new height
}
// One tooltip for the chart: it glides to the bar (--tx, --ty) and its text changes on the way
function place(i) {
clearTimeout(hideTimer);
const r = shown.rows[i];
const wasOn = tipEl.classList.contains('is-on');
if (!wasOn) tipEl.style.transition = 'none'; // from hidden: appear in place, don't fly in
tipEl.textContent = `${r.label} · ${money(r.value)}`;
tipEl.style.setProperty('--i', i);
// plain numbers in the chart's own units: CSS multiplies them by --u
tipEl.style.setProperty('--tx', String(i * PITCH + W / 2));
tipEl.style.setProperty('--ty', String((1 - r.value / shown.top) * H));
tipEl.classList.toggle('is-peak', i === shown.peak);
if (!wasOn) {
void tipEl.offsetWidth; // apply the new place before the transition comes back
tipEl.style.transition = '';
}
tipEl.classList.add('is-on');
bars.forEach((b, k) => b.classList.toggle('is-active', k === i)); // the bar fills in and glows
active = i;
}
// a short grace period, so crossing the gap between two bars does not flicker it
function hide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
tipEl.classList.remove('is-on');
bars.forEach((b) => b.classList.remove('is-active'));
active = -1;
}, 150);
}
const over = (e) => {
const bar = e.target.closest('.bar');
if (bar) place(bars.indexOf(bar));
};
const leave = (e) => {
if (!e.relatedTarget?.closest?.('.bar')) hide();
};
chart.addEventListener('pointerover', over);
chart.addEventListener('focusin', over);
chart.addEventListener('pointerout', leave);
chart.addEventListener('focusout', leave);
// a finger has no hover: a tap on a bar shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
if (e.pointerType === 'mouse') return;
const bar = e.target.closest('.bar');
if (bar) place(bars.indexOf(bar));
else hide();
});
buttons.forEach((b) => b.addEventListener('click', () => show(b.dataset.year)));
show('2024');