HTML 11 lines
<div class="chart">
<div class="scene">
<div class="bars3d"></div>
</div>
<output></output>
<div class="seg">
<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>
CSS 221 lines
.chart {
display: grid;
justify-items: center;
gap: 10px;
font-family: system-ui, sans-serif;
}
.scene {
perspective: 800px;
padding: 30px 40px 40px;
pointer-events: none; /* the chart is turned: only the bars take the pointer */
}
/* 6 bars × 19px + 5 gaps × 10px = 164px */
.bars3d {
position: relative;
width: 164px;
height: 100px; /* 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 {
position: absolute;
left: -12px;
top: 74px;
width: 188px;
height: 52px;
border: 1px solid rgb(139 108 255 / 0.45);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, rgb(139 108 255 / 0.24) 0 1px, transparent 1px 17px),
repeating-linear-gradient(rgb(139 108 255 / 0.24) 0 1px, transparent 1px 17px),
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: -10px; /* 10px of room above the scale, so the top number is not clipped */
left: -12px;
width: 188px;
height: 110px;
transform: translateZ(-26px);
}
.wall b {
position: absolute;
right: 0;
bottom: calc(var(--t) * 100px);
left: 0;
height: 1px;
background: rgb(236 238 251 / 0.26);
}
.wall span {
position: absolute;
top: -6px;
left: 100%;
padding-left: 6px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
}
.bars {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 10px;
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: 19px;
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) and it glides
there, text changing on the way. 40px 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 */
.tip {
--c: color-mix(in srgb, #8b6cff, #2ee6d6 calc(var(--i, 0) * 20%));
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-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: 19px;
height: 100%;
/* a fine edge: under 1px shows as a hairline on sharp screens */
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);
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 20px 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(9.5px) 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(9.5px) scaleY(var(--v));
}
/* the lid: a 19px square laid flat, riding down with the value */
.bar i:nth-child(3) {
height: 19px;
background: color-mix(in srgb, var(--c) 80%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--v)) * 100px)) rotateX(90deg) translateZ(9.5px);
}
.bar span {
position: absolute;
top: calc(100% + 8px);
left: -7px;
width: 33px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
text-align: center;
transform: translateZ(9.5px);
}
output {
color: #949bc0;
font-size: 12px;
}
.seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid rgb(255 255 255 / 0.14);
border-radius: 999px;
}
.seg button {
padding: 4px 14px;
border: 0;
border-radius: 999px;
background: transparent;
color: #949bc0;
font: 700 12px system-ui, sans-serif;
cursor: pointer;
}
.seg button[aria-pressed='true'] {
background: linear-gradient(135deg, #8b6cff, #ff4d9d);
color: #fff;
}
JS 126 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('.seg 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 (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);
tipEl.style.setProperty('--tx', i * PITCH + W / 2 + 'px');
tipEl.style.setProperty('--ty', (1 - r.value / shown.top) * H + 'px');
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');
Sass source 253 lines
// A bar chart drawn from JSON. JS writes one number per bar, --v: its value divided by the top of
// the scale (0–1). Each bar is a full-height box whose walls are squashed with scaleY(--v) and
// whose lid rides down by (1 − --v) of the height, so a new dataset is only a transition on
// transform: no layout, and every bar starts a little after the one before (--i).
$w: 19px; // bar width, and depth
$gap: 10px;
$h: 100px; // the top of the scale
$floor: 52px; // the floor's depth, front to back
$n: 6;
$span: $w * $n + $gap * ($n - 1);
$ease: cubic-bezier(0.3, 1.3, 0.5, 1); // past 1: a bar overshoots and settles
.d-neonbars {
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 bars sit behind the plane of the flat boxes around it (this
// one and the view), which would catch the pointer first: only the bars and the dock take it.
pointer-events: none;
&__view {
display: grid;
place-items: center;
min-height: 0;
pointer-events: none;
}
&__chart {
position: relative;
width: $span;
height: $h;
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-28deg);
}
// the floor: a neon grid laid flat along the bottom of the bars
&__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) 24%, transparent) 0 1px, transparent 1px 17px),
repeating-linear-gradient(color-mix(in srgb, var(--accent) 24%, transparent) 0 1px, transparent 1px 17px),
color-mix(in srgb, var(--accent) 10%, transparent);
transform: rotateX(90deg);
}
// the scale: lines at 0, half and the top, on a wall along the back edge of the floor. Its
// numbers are at the right end, which reaches out past the last bar (the left end is hidden
// behind the first one).
// (its box starts 10px above the scale: a turned layer is clipped at its box, and the top
// number, which pokes out above its line, was cut in half)
&__wall {
position: absolute;
top: -10px;
left: -12px;
width: $span + 24px;
height: $h + 10px;
transform: translateZ($floor * -0.5);
b {
position: absolute;
right: 0;
bottom: calc(var(--t) * #{$h});
left: 0;
height: 1px;
background: color-mix(in srgb, var(--text) 26%, 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;
}
}
&__bars {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: $gap;
transform-style: preserve-3d;
}
&__bar {
// violet → teal along the row; the tallest bar of the dataset turns pink
--c: color-mix(in srgb, var(--accent), var(--accent-2) calc(var(--i) * 20%));
position: relative;
flex: none;
width: $w;
height: 100%;
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
cursor: pointer;
&.is-peak {
--c: var(--hot);
}
// see-through glass faces with a bright edge: the neon look
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);
transform-origin: bottom center;
transition: transform 0.8s $ease calc(var(--i) * 60ms);
// the pointed-at bar 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
i:nth-child(1) {
transform: 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: rotateY(90deg) translateZ($w * 0.5) scaleY(var(--v));
}
// the lid: a $w square laid flat, riding down with the value
i:nth-child(3) {
height: $w;
background: color-mix(in srgb, var(--c) 80%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--v)) * #{$h})) rotateX(90deg) translateZ($w * 0.5);
}
// the label, standing in front of the bar
span {
position: absolute;
top: calc(100% + 8px);
left: -7px;
width: $w + 14px;
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
text-align: center;
transform: translateZ($w * 0.5);
}
}
// One tooltip for the chart: JS moves it to the pointed-at bar (--tx, --ty, --i) and CSS glides
// it there, so it slides from bar to bar with its text changing instead of blinking. It lives
// in the chart's turned plane, 40px towards you (each bar to the right stands nearer than the
// one before). Its thickness is a flat edge offset up and to the right, the way the bars'
// depth runs: a real plate behind it was split into strips by the browser's depth sorting.
&__tip {
--c: color-mix(in srgb, var(--accent), var(--accent-2) calc(var(--i, 0) * 20%));
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-peak {
--c: var(--hot);
}
&.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;
}
}
&__seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: color-mix(in srgb, var(--bg) 70%, transparent);
button {
padding: 4px 14px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
&[aria-pressed='true'] {
background: linear-gradient(135deg, var(--accent), var(--hot));
color: #fff;
}
}
}
}