3D bar chart
Pure CSSEach bar is a real cuboid — front, side and top face — growing on a staggered delay.
A speedometer dial tilted in perspective, with raised ticks and a needle stacked from four layers. The buttons only set --value; CSS turns it into an angle and an overshooting transition swings the needle there.
Your edited version
-120deg, 100 at +120deg. So the needle is rotate(calc(-120deg + var(--value) * 2.4deg)), and JS only ever writes --value.transition. Its cubic-bezier(0.3, 1.6, 0.45, 1) goes above 1, so the needle shoots past the mark and settles back, like a real one.repeating-conic-gradient, cut down by two masks intersected (mask-composite: intersect): a ring, and the 240° of the scale.--value → rotate(calc(-120deg + v × 2.4deg))overshoot cubic-bezier transitionneedle: 4 translateZ layersminor ticks: repeating-conic-gradient + mask<div class="gauge">
<div class="scene">
<div class="dial" style="--value:22">
<i></i><i></i><i></i><i></i><i></i><i></i>
<div class="face">
<b style="--a:-120deg"></b>
<b style="--a:-96deg"></b>
<b style="--a:-72deg"></b>
<b style="--a:-48deg"></b>
<b style="--a:-24deg"></b>
<b style="--a:0deg"></b>
<b style="--a:24deg"></b>
<b style="--a:48deg"></b>
<b style="--a:72deg"></b>
<b style="--a:96deg"></b>
<b style="--a:120deg"></b>
<em style="--a:-120deg">0</em>
<em style="--a:-72deg">20</em>
<em style="--a:-24deg">40</em>
<em style="--a:24deg">60</em>
<em style="--a:72deg">80</em>
<em style="--a:120deg">100</em>
<small>km/h</small>
</div>
<div class="needle"><div class="quiver"><i></i><i></i><i></i><i></i></div></div>
<div class="hub"></div>
<div class="glass"></div>
</div>
</div>
<output>Low · 22 km/h</output>
<div class="seg">
<button type="button" data-value="22" aria-pressed="true">Low</button>
<button type="button" data-value="58" aria-pressed="false">Mid</button>
<button type="button" data-value="94" aria-pressed="false">High</button>
</div>
</div>
.gauge {
display: grid;
justify-items: center;
gap: 10px;
font-family: system-ui, sans-serif;
}
.gauge * {
box-sizing: border-box;
}
.scene {
perspective: 800px;
margin-bottom: 14px;
}
.dial {
position: relative;
width: 150px;
height: 150px;
transform-style: preserve-3d;
transform: rotateX(32deg) rotateY(-14deg);
animation: sway 10s ease-in-out infinite alternate;
}
/* the case: six discs stepping back, each darker; tilted, their edges read as a solid rim */
.dial > i {
position: absolute;
inset: 0;
border-radius: 50%;
background: color-mix(in srgb, #4e4290, #05060c calc(var(--n) * 9%));
transform: translateZ(calc(var(--n) * -2.5px));
}
.dial > i:nth-child(1) { --n: 1; }
.dial > i:nth-child(2) { --n: 2; }
.dial > i:nth-child(3) { --n: 3; }
.dial > i:nth-child(4) { --n: 4; }
.dial > i:nth-child(5) { --n: 5; }
.dial > i:nth-child(6) { --n: 6; }
.face {
position: absolute;
inset: 0;
border: 6px solid #b0a6ee;
border-radius: 50%;
background: radial-gradient(circle at 50% 35%, #2e2b5e, #141830 75%);
box-shadow: inset 0 0 18px rgb(0 0 0 / 0.4);
transform-style: preserve-3d;
}
/* the coloured zones: a conic gradient masked down to a thin arc */
.face::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: conic-gradient(from -120deg, #2ee6d6, #ffb547 150deg, #ff4d9d 210deg 240deg, transparent 0);
mask: radial-gradient(closest-side, transparent calc(100% - 5px), #000 calc(100% - 4.5px));
}
/* minor ticks every 4.8deg, masked to a ring AND to the 240deg of the scale */
.face::after {
content: '';
position: absolute;
inset: 12px;
border-radius: 50%;
background: repeating-conic-gradient(from -120.5deg, rgb(236 238 251 / 0.55) 0 1deg, transparent 1deg 4.8deg);
mask:
radial-gradient(closest-side, transparent calc(100% - 5px), #000 calc(100% - 4.5px)),
conic-gradient(from -121deg, #000 0 242deg, transparent 0);
mask-composite: intersect;
}
/* major ticks: real bars, lifted off the face */
.face b {
position: absolute;
left: calc(50% - 1.5px);
top: calc(50% - 5px);
width: 3px;
height: 10px;
border-radius: 1px;
background: #eceefb;
transform: translateZ(2px) rotate(var(--a)) translateY(-50px);
}
/* numbers: out along the angle, then turned back upright */
.face em {
position: absolute;
left: calc(50% - 12px);
top: calc(50% - 6px);
width: 24px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
font-style: normal;
text-align: center;
transform: translateZ(1px) rotate(var(--a)) translateY(-33px) rotate(calc(var(--a) * -1));
}
.face small {
position: absolute;
inset: auto 0 20px;
color: #949bc0;
font: 800 8px system-ui, sans-serif;
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
transform: translateZ(1px);
}
/* JS writes --value; this turns it into an angle, the transition does the swing */
.needle {
position: absolute;
left: 50%;
top: 50%;
transform-style: preserve-3d;
transform: rotate(calc(-120deg + var(--value) * 2.4deg));
transition: transform 1.3s cubic-bezier(0.3, 1.6, 0.45, 1); /* > 1: overshoot */
}
.quiver {
position: absolute;
transform-style: preserve-3d;
animation: quiver 1.7s ease-in-out infinite alternate;
}
/* the needle: four layers of one shape, 1.3px apart, lightest on top */
.quiver i {
position: absolute;
left: -4px;
top: -58px;
width: 8px;
height: 72px;
clip-path: polygon(50% 0, 76% 80%, 62% 100%, 38% 100%, 24% 80%);
background: #852852;
transform: translateZ(3.3px);
}
.quiver i:nth-child(2) { background: #ad346b; transform: translateZ(4.6px); }
.quiver i:nth-child(3) { background: #d64184; transform: translateZ(5.9px); }
.quiver i:nth-child(4) { background: linear-gradient(90deg, #ff9dc9 50%, #ff4d9d 50%); transform: translateZ(7.2px); }
.hub {
position: absolute;
left: calc(50% - 11px);
top: calc(50% - 11px);
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, #b9bed8 32%, #474c6b 78%);
box-shadow: 0 0 0 2px #ff4d9d;
transform: translateZ(9px);
}
.glass {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(150deg, rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.04) 44%, transparent 45%);
pointer-events: none;
transform: translateZ(12px);
}
output {
color: #949bc0;
font-size: 14px;
}
.seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: 999px;
}
.seg button {
padding: 6px 18px;
border: 0;
border-radius: 999px;
background: transparent;
color: #949bc0;
font: 700 14px system-ui, sans-serif;
cursor: pointer;
}
.seg button[aria-pressed='true'] {
background: linear-gradient(135deg, #8b6cff, #ff4d9d);
color: #fff;
}
@keyframes sway {
from { transform: rotateX(32deg) rotateY(-16deg); }
to { transform: rotateX(28deg) rotateY(10deg); }
}
@keyframes quiver {
from { transform: rotate(-1deg); }
to { transform: rotate(1deg); }
}
const dial = document.querySelector('.dial');
const out = document.querySelector('output');
const buttons = document.querySelectorAll('.seg button');
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
// only a number goes to CSS; the angle and the swing are CSS
dial.style.setProperty('--value', btn.dataset.value);
out.textContent = btn.textContent + ' · ' + btn.dataset.value + ' km/h';
buttons.forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
});
});
// A value 0–100 sweeps 240deg: 0 → -120deg, 100 → +120deg, i.e. rotate(-120deg + v · 2.4deg).
// JS only writes --value on the dial. The needle turns to the matching angle with a transition
// whose cubic-bezier goes past 1: it swings beyond the mark and settles back.
$size: 150px;
$rim: color-mix(in srgb, var(--accent) 45%, #1c1f36);
.d-gauge {
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 demo with controls
&__view {
display: grid;
place-items: center;
min-height: 0;
}
&__dial {
--value: 22;
position: relative;
width: $size;
height: $size;
transform-style: preserve-3d;
transform: rotateX(32deg) rotateY(-14deg);
animation: d-gauge-sway 10s ease-in-out infinite alternate;
// The case: six copies of the disc stepping back 2.5px, each a little darker. Seen tilted,
// their edges read as a solid rim below the face.
> i {
position: absolute;
inset: 0;
border-radius: 50%;
}
@for $n from 1 through 6 {
> i:nth-child(#{$n}) {
background: color-mix(in srgb, $rim, #05060c #{$n * 9%});
transform: translateZ(-2.5px * $n);
}
}
}
&__face {
position: absolute;
inset: 0;
border: 6px solid color-mix(in srgb, var(--accent) 40%, #c9cde2);
border-radius: 50%;
background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--accent) 22%, var(--surface-solid)), var(--surface-solid) 75%);
box-shadow: inset 0 0 18px rgb(0 0 0 / 0.4);
transform-style: preserve-3d;
// the coloured zones: a conic gradient cut to a thin arc by a radial mask (on this leaf only)
&::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: conic-gradient(from -120deg, var(--accent-2), var(--warm) 150deg, var(--hot) 210deg 240deg, transparent 0);
mask: radial-gradient(closest-side, transparent calc(100% - 5px), #000 calc(100% - 4.5px));
}
// minor ticks every 2 units (4.8deg): a repeating conic gradient, masked to a ring and to the
// 240deg of the scale (two masks, intersected)
&::after {
content: '';
position: absolute;
inset: 12px;
border-radius: 50%;
background: repeating-conic-gradient(from -120.5deg, color-mix(in srgb, var(--text) 55%, transparent) 0 1deg, transparent 1deg 4.8deg);
mask:
radial-gradient(closest-side, transparent calc(100% - 5px), #000 calc(100% - 4.5px)),
conic-gradient(from -121deg, #000 0 242deg, transparent 0);
mask-composite: intersect;
}
// major ticks: real bars, raised off the face
b {
position: absolute;
left: calc(50% - 1.5px);
top: calc(50% - 5px);
width: 3px;
height: 10px;
border-radius: 1px;
background: var(--text);
transform: translateZ(2px) rotate(var(--a)) translateY(-50px);
}
// numbers: out along the angle, then turned back upright
em {
position: absolute;
left: calc(50% - 12px);
top: calc(50% - 6px);
width: 24px;
color: var(--muted);
font: 700 9px/12px var(--font);
font-style: normal;
text-align: center;
transform: translateZ(1px) rotate(var(--a)) translateY(-33px) rotate(calc(var(--a) * -1));
}
small {
position: absolute;
inset: auto 0 20px;
color: var(--muted);
font: 800 8px var(--font);
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
transform: translateZ(1px);
}
}
&__needle {
position: absolute;
left: 50%;
top: 50%;
transform-style: preserve-3d;
transform: rotate(calc(-120deg + var(--value) * 2.4deg));
transition: transform 1.3s cubic-bezier(0.3, 1.6, 0.45, 1);
}
// a slow, small tremble, like a live reading
&__quiver {
position: absolute;
transform-style: preserve-3d;
animation: d-gauge-quiver 1.7s ease-in-out infinite alternate;
// the needle: four layers of the same shape, stacked 1.3px apart, lightest on top
i {
position: absolute;
left: -4px;
top: -58px;
width: 8px;
height: 72px;
clip-path: polygon(50% 0, 76% 80%, 62% 100%, 38% 100%, 24% 80%);
}
@for $n from 1 through 4 {
i:nth-child(#{$n}) {
background: color-mix(in srgb, var(--hot), #000 #{(4 - $n) * 16%});
transform: translateZ(2px + $n * 1.3px);
}
}
i:nth-child(4) {
background: linear-gradient(90deg, color-mix(in srgb, var(--hot) 55%, #fff) 50%, var(--hot) 50%);
}
}
&__hub {
position: absolute;
left: calc(50% - 11px);
top: calc(50% - 11px);
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, #b9bed8 32%, #474c6b 78%);
box-shadow: 0 0 0 2px var(--hot);
transform: translateZ(9px);
}
// the glass: a faint sheen in front of everything
&__glass {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(150deg, rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.04) 44%, transparent 45%);
pointer-events: none;
transform: translateZ(12px);
}
// the site's control pattern: the status centred on top, the buttons centred under it
&__bar {
display: grid;
justify-items: center;
gap: 6px;
font-size: 12px;
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;
cursor: pointer;
&[aria-pressed='true'] {
background: linear-gradient(135deg, var(--accent), var(--hot));
color: #fff;
}
}
}
}
@keyframes d-gauge-sway {
from {
transform: rotateX(32deg) rotateY(-16deg);
}
to {
transform: rotateX(28deg) rotateY(10deg);
}
}
@keyframes d-gauge-quiver {
from {
transform: rotate(-1deg);
}
to {
transform: rotate(1deg);
}
}