HTML 38 lines
<div class="gauge">
<div class="view">
<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>
<div class="controls">
<output class="caption">Low · 22 km/h</output>
<div class="row">
<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>
</div>
CSS 245 lines
/* the model box and the control zone stand in one stack, so the zone is the same distance
under the dial in every model */
.gauge {
/* one base unit: every length of the dial is a multiple of it. The control zone is in plain
vmin, because it is the same object in every model. */
--u: 0.3vmin;
display: grid;
justify-items: center;
gap: 4vmin;
font-family: Inter, system-ui, sans-serif;
}
.gauge * {
box-sizing: border-box;
}
/* the model box */
.view {
display: grid;
place-items: center;
height: 50vmin;
perspective: calc(800 * var(--u));
}
.dial {
position: relative;
width: calc(150 * var(--u));
height: calc(150 * var(--u));
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.5 * var(--u)));
}
.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: calc(6 * var(--u)) solid #b0a6ee;
border-radius: 50%;
background: radial-gradient(circle at 50% 35%, #2e2b5e, #141830 75%);
box-shadow: inset 0 0 calc(18 * var(--u)) 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: calc(3 * var(--u));
border-radius: 50%;
background: conic-gradient(from -120deg, #2ee6d6, #ffb547 150deg, #ff4d9d 210deg 240deg, transparent 0);
mask: radial-gradient(closest-side, transparent calc(100% - calc(5 * var(--u))), #000 calc(100% - calc(4.5 * var(--u))));
}
/* minor ticks every 4.8deg, masked to a ring AND to the 240deg of the scale */
.face::after {
content: '';
position: absolute;
inset: calc(12 * var(--u));
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% - calc(5 * var(--u))), #000 calc(100% - calc(4.5 * var(--u)))),
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% - calc(1.5 * var(--u)));
top: calc(50% - calc(5 * var(--u)));
width: calc(3 * var(--u));
height: calc(10 * var(--u));
border-radius: calc(1 * var(--u));
background: #eceefb;
transform: translateZ(calc(2 * var(--u))) rotate(var(--a)) translateY(calc(-50 * var(--u)));
}
/* numbers: out along the angle, then turned back upright */
.face em {
position: absolute;
left: calc(50% - calc(14 * var(--u)));
top: calc(50% - calc(7 * var(--u)));
width: calc(28 * var(--u));
color: #b4b9d9; /* lighter than the muted #949bc0, which was 4.2:1 on the dial's face */
font: 700 calc(11 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
font-style: normal;
text-align: center;
transform: translateZ(calc(1 * var(--u))) rotate(var(--a)) translateY(calc(-33 * var(--u))) rotate(calc(var(--a) * -1));
}
.face small {
position: absolute;
inset: auto 0 calc(20 * var(--u));
color: #949bc0;
font: 800 calc(9 * var(--u)) Inter, system-ui, sans-serif;
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
transform: translateZ(calc(1 * var(--u)));
}
/* 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, calc(1.3 * var(--u)) apart, lightest on top */
.quiver i {
position: absolute;
left: calc(-4 * var(--u));
top: calc(-58 * var(--u));
width: calc(8 * var(--u));
height: calc(72 * var(--u));
clip-path: polygon(50% 0, 76% 80%, 62% 100%, 38% 100%, 24% 80%);
background: #852852;
transform: translateZ(calc(3.3 * var(--u)));
}
.quiver i:nth-child(2) { background: #ad346b; transform: translateZ(calc(4.6 * var(--u))); }
.quiver i:nth-child(3) { background: #d64184; transform: translateZ(calc(5.9 * var(--u))); }
.quiver i:nth-child(4) { background: linear-gradient(90deg, #ff9dc9 50%, #ff4d9d 50%); transform: translateZ(calc(7.2 * var(--u))); }
.hub {
position: absolute;
left: calc(50% - calc(11 * var(--u)));
top: calc(50% - calc(11 * var(--u)));
width: calc(22 * var(--u));
height: calc(22 * var(--u));
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, #b9bed8 32%, #474c6b 78%);
box-shadow: 0 0 0 calc(2 * var(--u)) #ff4d9d;
transform: translateZ(calc(9 * var(--u)));
}
.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(calc(12 * var(--u)));
}
/* the control zone: the same object, at the same size, in every model that has one. The
caption's line box never changes height, so a new reading cannot move the dial. */
.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);
}
@keyframes sway {
from { transform: rotateX(32deg) rotateY(-16deg); }
to { transform: rotateX(28deg) rotateY(10deg); }
}
@keyframes quiver {
from { transform: rotate(-1deg); }
to { transform: rotate(1deg); }
}
JS 12 lines
const dial = document.querySelector('.dial');
const out = document.querySelector('.controls output');
const buttons = document.querySelectorAll('.controls 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)));
});
});