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.

  • 200 lines of CSS
  • 36 lines of HTML
  • 12 lines of JS
  • No dependencies
  • MIT licensed

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.5px each, getting darker, and read as a solid rim when tilted. The needle is four clip-path layers 1.3px apart; the ticks are real bars lifted off the face, and a glass sheen floats 12px in front.
  4. 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 36 lines

<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>

CSS 200 lines

.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); }
}

JS 12 lines

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)));
  });
});

Sass source 232 lines

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