Back to the library

Gauge — damped needle

Variant: Damped needle

A dial with two tellers: a thin clay tick that jumps to each new sample the instant it lands, and a needle that takes 364ms to get there on a slightly underdamped curve. The tick is the truth and the needle is the story — wire a needle straight to the signal and the display is as unreadable as the noise it measures; damping trades now for legible, and the small overshoot is the honest cost of a needle tuned to move fast. The readout rides the same windows as the needle but on a plain deceleration — it never prints the overshoot, because the number an instrument shows should be one the signal actually contained. Reach for it when documenting why dashboards smooth, or anywhere a raw feed needs to be seen beside what the UI made of it.

datameterstatussvgloop

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="animation-knobs" content="overshoot: ok" />
    <title>Gauge — damped needle</title>
    <style>
      /* One state, two renderings: a number places the needle and an integer
         renders the readout off the same keyframes, so the dial and the digits
         cannot disagree on any frame. */
      @property --_v {
        syntax: '<number>';
        inherits: true;
        initial-value: 34;
      }
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 34;
      }

      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — 5.6s: five readings at irregular gaps. The raw tick takes
           each one instantly; the needle spends 364ms getting there. */
        --cycle: 5.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* Slightly underdamped on purpose — the small overshoot is the price
           of a needle that gets near the truth quickly. */
        --ease-accent: cubic-bezier(0.34, 1.3, 0.44, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .gauge {
        width: min(228px, 74vw);
        padding: 16px 18px 13px;
        border-radius: calc(14px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
        animation:
          signal var(--t) infinite,
          readout var(--t) infinite;
      }

      /* The signal. Every hold is dead still — nothing was reported, so
         nothing moves — and every move gets the same 6.5% window, because a
         damped needle takes the same settle time wherever the value lands.
         Each move departs 56ms after its sample: display latency, the beat
         it takes the instrument to notice. */
      @keyframes signal {
        0%,
        9% {
          --_v: 34;
          animation-timing-function: var(--ease-accent);
        }
        15.5%,
        23% {
          --_v: 72;
          animation-timing-function: var(--ease-accent);
        }
        29.5%,
        47% {
          --_v: 58;
          animation-timing-function: var(--ease-accent);
        }
        53.5%,
        61% {
          --_v: 91;
          animation-timing-function: var(--ease-accent);
        }
        67.5%,
        83% {
          --_v: 22;
          animation-timing-function: var(--ease-accent);
        }
        89.5%,
        100% {
          --_v: 34;
        }
      }

      /* The digits ride the same windows on a plain deceleration — no
         overshoot, because a needle passing 92 is physics and a readout
         printing 92 is a value the signal never contained. */
      @keyframes readout {
        0%,
        9% {
          --_n: 34;
          animation-timing-function: var(--ease-out);
        }
        15.5%,
        23% {
          --_n: 72;
          animation-timing-function: var(--ease-out);
        }
        29.5%,
        47% {
          --_n: 58;
          animation-timing-function: var(--ease-out);
        }
        53.5%,
        61% {
          --_n: 91;
          animation-timing-function: var(--ease-out);
        }
        67.5%,
        83% {
          --_n: 22;
          animation-timing-function: var(--ease-out);
        }
        89.5%,
        100% {
          --_n: 34;
        }
      }

      svg {
        display: block;
        width: 100%;
        height: auto;
      }

      .arc {
        fill: none;
        stroke: color-mix(in srgb, var(--oat) 70%, transparent);
        stroke-width: 3;
        stroke-linecap: round;
      }

      .tick {
        stroke: color-mix(in srgb, var(--slate) 34%, transparent);
        stroke-width: 2;
        stroke-linecap: round;
      }

      .needle {
        stroke: var(--slate);
        stroke-width: 3;
        stroke-linecap: round;
        transform: rotate(calc((var(--_v) - 50) * 1.8deg));
        transform-origin: 110px 104px;
        transform-box: view-box;
      }

      /* The sensor itself: a thin clay tick that beats the needle to every
         reading. steps(1, end) because a sample has no in-between — the value
         was 34 and then it was 72. */
      .raw {
        stroke: var(--clay);
        stroke-width: 2;
        stroke-linecap: round;
        opacity: 0.85;
        transform-origin: 110px 104px;
        transform-box: view-box;
        animation: raw var(--t) steps(1, end) infinite;
      }

      @keyframes raw {
        0% {
          transform: rotate(-28.8deg);
        }
        8% {
          transform: rotate(39.6deg);
        }
        22% {
          transform: rotate(14.4deg);
        }
        46% {
          transform: rotate(73.8deg);
        }
        60% {
          transform: rotate(-50.4deg);
        }
        82%,
        100% {
          transform: rotate(-28.8deg);
        }
      }

      .hub {
        fill: var(--slate);
      }

      .readout {
        display: flex;
        align-items: baseline;
        justify-content: center;
        gap: 7px;
        margin-top: 2px;
      }

      .num {
        font-size: 21px;
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        min-width: 3ch;
        text-align: right;
        counter-reset: v var(--_n);
      }

      .num::after {
        content: counter(v) '%';
      }

      .cap {
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--slate) 48%, transparent);
      }

      @media (prefers-reduced-motion: reduce) {
        /* The needle becomes the raw tick: it steps to each reading with no
           travel, and the sensor overlay goes because it would now be a
           duplicate. The readings still land — that is the datum. */
        .gauge {
          animation: signal-rm calc(var(--t) * 1.7) steps(1, end) infinite;
        }

        .raw {
          animation: none;
          opacity: 0;
        }

        @keyframes signal-rm {
          0% {
            --_v: 34;
            --_n: 34;
          }
          15.5% {
            --_v: 72;
            --_n: 72;
          }
          29.5% {
            --_v: 58;
            --_n: 58;
          }
          53.5% {
            --_v: 91;
            --_n: 91;
          }
          67.5% {
            --_v: 22;
            --_n: 22;
          }
          89.5%,
          100% {
            --_v: 34;
            --_n: 34;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="gauge"
      role="img"
      aria-label="A dial gauge: a thin raw tick jumps instantly to each new reading while the damped needle swings after it, overshoots slightly, and settles"
    >
      <svg viewBox="0 0 220 118" aria-hidden="true">
        <path class="arc" d="M 36 104 A 74 74 0 0 1 184 104" />
        <line class="tick" x1="36" y1="104" x2="44" y2="104" />
        <line class="tick" x1="57.7" y1="51.7" x2="63.3" y2="57.3" />
        <line class="tick" x1="110" y1="30" x2="110" y2="38" />
        <line class="tick" x1="162.3" y1="51.7" x2="156.7" y2="57.3" />
        <line class="tick" x1="184" y1="104" x2="176" y2="104" />
        <line class="raw" x1="110" y1="31" x2="110" y2="42" />
        <line class="needle" x1="110" y1="44" x2="110" y2="112" />
        <circle class="hub" cx="110" cy="104" r="4.5" />
      </svg>
      <div class="readout">
        <span class="num"></span>
        <span class="cap">Load</span>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.