CSS + JS Data & tools #controls #data #chart #dial

3D gauge in CSS + JavaScript

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.

  • 245 lines of CSS
  • 38 lines of HTML
  • 12 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The scale runs 240°: value 0 sits at -120deg, 100 at +120deg. So the needle is rotate(calc(-120deg + var(--value) * 2.4deg)), and JS only ever writes --value.
  2. The swing is a CSS 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.
  3. Depth comes from stacking: six copies of the disc step back 2.5 units each, getting darker, and read as a solid rim when tilted. The needle is four clip-path layers 1.3 units apart; the ticks are real bars lifted off the face, and a glass sheen floats 12 units in front.
  4. Every length of the dial is a multiple of one base unit, --u, so it is the same share of a gallery card, the editor and a recording canvas. The caption and the buttons under it are in plain vmin: the control zone is the same object, at the same size, in every model.
  5. The minor ticks are one repeating-conic-gradient, cut down by two masks intersected (mask-composite: intersect): a ring, and the 240° of the scale.

Key techniques

  • --value → rotate(calc(-120deg + v × 2.4deg))
  • overshoot cubic-bezier transition
  • needle: 4 translateZ layers
  • minor ticks: repeating-conic-gradient + mask

Copy-paste code

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)));
  });
});
Standalone snippet · plain CSS, no build step, no dependencies.