3D bar chart
Pure CSSEach bar is a real cuboid — front, side and top face — growing on a staggered delay.
Points placed with translate3d inside a wireframe box that spins by CSS. Dragging pauses the spin and adds a manual rotation; every point counter-rotates so it stays a round sphere.
Your edited version
rotateX(--rx) › spin rotateY(0 → 360deg) as a CSS animation › cube rotateY(--ry). Dragging sets the two variables and pauses the spin with animation-play-state.translate property (translate: x y z) inside the wireframe cube, so each one keeps its position whatever else it does.rotate property (0 → -360deg, same timing as the spin), the variable ones stay in a static transform. No custom property ever has to be read inside keyframes.touch-action: none plus pointer capture makes the drag work with a finger.translate3d(x, y, z) per pointnested rotations: tilt › spin › draganimation-play-state while draggingbillboard: static inverse + animated rotate property<div class="plot">
<div class="tilt">
<div class="spin">
<div class="cube">
<u></u><u></u><u></u><u></u><u></u><u></u>
<s></s><s></s><s></s>
<b class="label" style="--x:84px;--y:70px;--z:-70px">x</b>
<b class="label" style="--x:-70px;--y:-84px;--z:-70px">y</b>
<b class="label" style="--x:-70px;--y:70px;--z:84px">z</b>
</div>
</div>
</div>
<small class="hint">drag to rotate</small>
</div>
.plot {
position: fixed;
inset: 0;
display: grid;
place-items: center;
perspective: 700px;
cursor: grab;
touch-action: none;
user-select: none;
}
.plot.is-drag {
cursor: grabbing;
}
.tilt {
position: relative;
width: 140px;
height: 140px;
margin-top: -14px; /* room for the hint */
transform-style: preserve-3d;
transform: scale3d(0.92, 0.92, 0.92) rotateX(var(--rx, -18deg));
}
.spin {
position: absolute;
inset: 0;
transform-style: preserve-3d;
animation: spin 28s linear infinite;
}
.cube {
--s: 140px;
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateY(var(--ry, 0deg));
}
/* wireframe walls with a faint grid */
.cube u {
position: absolute;
inset: 0;
border: 1px solid rgb(236 238 251 / 0.22);
background:
linear-gradient(90deg, rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 35px 35px,
linear-gradient(rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 35px 35px;
}
/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
/* axes from the back-bottom-left corner */
.cube s {
position: absolute;
left: 0;
top: 100%;
width: 148px;
height: 2px;
background: rgb(236 238 251 / 0.55);
transform-origin: 0 50%;
transform: translateZ(-70px); /* x */
}
.cube s:nth-of-type(2) { transform: translateZ(-70px) rotateZ(-90deg); } /* y */
.cube s:nth-of-type(3) { transform: translateZ(-70px) rotateY(-90deg); } /* z */
.cube b {
position: absolute;
top: calc(50% - 5px);
left: calc(50% - 5px);
width: 10px;
height: 10px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, var(--c) 50%, color-mix(in srgb, var(--c) 45%, #000));
box-shadow: 0 0 8px var(--c);
translate: var(--x) var(--y) var(--z);
/* undo the cube's turn and the tilt (static)... */
transform: rotateY(calc(var(--ry, 0deg) * -1)) rotateX(calc(var(--rx, -18deg) * -1));
/* ...and the spin (animated, on its own property) */
animation: face 28s linear infinite;
}
.cube .label {
display: grid;
place-items: center;
background: none;
box-shadow: none;
color: #949bc0;
font: 700 12px/1 monospace;
}
.is-drag .spin,
.is-drag .cube b {
animation-play-state: paused;
}
.hint {
position: absolute;
inset: auto 0 10px;
color: #949bc0;
font: 12px system-ui;
text-align: center;
transition: opacity 0.4s;
}
.is-touched .hint {
opacity: 0;
}
@keyframes spin {
to { transform: rotateY(360deg); }
}
@keyframes face {
from { rotate: y 0deg; }
to { rotate: y -360deg; }
}
const plot = document.querySelector('.plot');
const cube = plot.querySelector('.cube');
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
// three loose clusters: centre x, y, z, spread, colour
const clusters = [
[-28, -26, -18, 46, '#8b6cff'],
[32, 8, 30, 40, '#2ee6d6'],
[-6, 36, -34, 34, '#ff4d9d'],
];
const blob = (c, spread) => clamp(c + (Math.random() + Math.random() + Math.random() - 1.5) * spread, -62, 62);
for (let n = 0; n < 42; n++) {
const [cx, cy, cz, spread, colour] = clusters[n % 3];
const p = document.createElement('b');
p.style.setProperty('--x', blob(cx, spread).toFixed(0) + 'px');
p.style.setProperty('--y', blob(cy, spread).toFixed(0) + 'px');
p.style.setProperty('--z', blob(cz, spread).toFixed(0) + 'px');
p.style.setProperty('--c', colour);
cube.append(p);
}
let rx = -18, ry = 0;
let last = null;
plot.addEventListener('pointerdown', (e) => {
last = { x: e.clientX, y: e.clientY };
plot.setPointerCapture(e.pointerId);
plot.classList.add('is-drag', 'is-touched');
});
plot.addEventListener('pointermove', (e) => {
if (!last) return;
ry += (e.clientX - last.x) * 0.5;
rx = clamp(rx - (e.clientY - last.y) * 0.4, -80, 80);
last = { x: e.clientX, y: e.clientY };
plot.style.setProperty('--rx', rx + 'deg');
plot.style.setProperty('--ry', ry + 'deg');
});
const up = () => {
last = null;
plot.classList.remove('is-drag');
};
plot.addEventListener('pointerup', up);
plot.addEventListener('pointercancel', up);
@use '../mixins' as *;
// Three nested rotations: tilt rotateX(--rx) set by dragging up/down
// spin rotateY(0 → 360) CSS animation, paused while dragging
// box rotateY(--ry) set by dragging sideways
// A point undoes all three, innermost first, so it always faces the camera:
// rotateY(-ry) static, in `transform`
// rotateY(-spin) animated, on the separate `rotate` property
// rotateX(-rx) static, in `transform`
// Both Y turns share one axis, so their order does not matter; that is what lets the animated
// part live in its own property while the variable parts stay out of the keyframes.
$size: 140px;
$spin: 28s;
.d-scatter {
position: absolute;
inset: 0;
display: grid;
place-items: center;
perspective: 700px;
cursor: grab;
touch-action: none; // let the finger drag the plot instead of scrolling the page
user-select: none;
&.is-drag {
cursor: grabbing;
}
&__tilt {
position: relative;
width: $size;
height: $size;
margin-top: -14px; // leave room for the hint
transform-style: preserve-3d;
transform: scale3d(0.92, 0.92, 0.92) rotateX(var(--rx, -18deg));
}
&__spin {
position: absolute;
inset: 0;
transform-style: preserve-3d;
animation: d-scatter-spin $spin linear infinite;
}
&__box {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateY(var(--ry, 0deg));
@include cube-faces($size);
// wireframe walls with a faint grid
u {
position: absolute;
inset: 0;
border: 1px solid color-mix(in srgb, var(--text) 22%, transparent);
background:
linear-gradient(90deg, color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 35px 35px,
linear-gradient(color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 35px 35px,
color-mix(in srgb, var(--accent) 4%, transparent);
}
// axes from the corner (-70, 70, -70): x to the right, y up, z toward the front
s {
position: absolute;
left: 0;
top: 100%;
width: $size + 8px;
height: 2px;
background: color-mix(in srgb, var(--text) 55%, transparent);
transform-origin: 0 50%;
transform: translateZ($size * -0.5);
}
s:nth-of-type(2) {
transform: translateZ($size * -0.5) rotateZ(-90deg);
}
s:nth-of-type(3) {
transform: translateZ($size * -0.5) rotateY(-90deg);
}
// points (added by JS) and axis labels share the billboard
b {
position: absolute;
top: calc(50% - 5px);
left: calc(50% - 5px);
width: 10px;
height: 10px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, var(--c) 50%, color-mix(in srgb, var(--c) 45%, #000));
box-shadow: 0 0 8px color-mix(in srgb, var(--c) 60%, transparent);
translate: var(--x) var(--y) var(--z);
transform: rotateY(calc(var(--ry, 0deg) * -1)) rotateX(calc(var(--rx, -18deg) * -1));
animation: d-scatter-face $spin linear infinite;
}
.is-label {
display: grid;
place-items: center;
background: none;
box-shadow: none;
color: var(--muted);
font: 700 12px/1 var(--mono);
}
}
// Dragging stops the CSS spin; box and points pause together, so they stay in step.
&.is-drag :is(.d-scatter__spin, b) {
animation-play-state: paused;
}
small {
position: absolute;
inset: auto 0 10px;
color: var(--muted);
font-size: 12px;
text-align: center;
pointer-events: none;
transition: opacity 0.4s;
}
&.is-touched small {
opacity: 0;
}
}
@keyframes d-scatter-spin {
to {
transform: rotateY(360deg);
}
}
@keyframes d-scatter-face {
from {
rotate: y 0deg;
}
to {
rotate: y -360deg;
}
}