Back to the library

Gauge — startup sweep

Variant: Startup sweep

The oldest trust gesture on a dashboard: at power-on the needle sweeps its full range and comes home before it shows anything real. The sweep is ease-in-out both ways — driven, machine motion, not a response — and the readout holds dashes through all of it, because a needle in self-test is not a measurement and the instrument refuses to print one. What the ritual buys is the held zero at the end: a flat reading later is now a true zero rather than a stuck needle. Only the first live value gets the arrival curve, and the label flips from SELF-TEST to LIVE on the frame it seats. Use it on monitoring panels and hardware-adjacent UI where the gauge itself has to earn belief before its first datum can.

datameterentrancesvgloop

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" />
    <title>Gauge — startup sweep</title>
    <style>
      @property --_v {
        syntax: '<number>';
        inherits: true;
        initial-value: 0;
      }
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 0;
      }

      :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: a 784ms sweep up, a beat at full scale, a 784ms sweep
           home, a held zero, then the first live reading. */
        --cycle: 5.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 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,
          life var(--t) ease infinite;
      }

      /* The self-test is machine motion — ease-in-out both ways, because the
         needle is being driven, not responding. Only the first live reading
         gets the arrival curve. The held zero at 39-46% is the point of the
         whole exercise: a zero you watched the needle return to. */
      @keyframes signal {
        0%,
        8% {
          --_v: 0;
          --_n: 0;
          animation-timing-function: var(--ease-in-out);
        }
        22%,
        25% {
          --_v: 100;
          --_n: 100;
          animation-timing-function: var(--ease-in-out);
        }
        39%,
        46% {
          --_v: 0;
          --_n: 0;
          animation-timing-function: var(--ease-out);
        }
        53%,
        64% {
          --_v: 58;
          --_n: 58;
          animation-timing-function: var(--ease-in-out);
        }
        68%,
        78% {
          --_v: 61;
          --_n: 61;
          animation-timing-function: var(--ease-in-out);
        }
        82%,
        96.5% {
          --_v: 57;
          --_n: 57;
        }
        97.5%,
        100% {
          --_v: 0;
          --_n: 0;
        }
      }

      @keyframes life {
        0%,
        1% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        4%,
        88% {
          opacity: 1;
        }
        91%,
        94% {
          opacity: 0.6;
        }
        96.5%,
        100% {
          opacity: 0;
        }
      }

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

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

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

      .val {
        display: grid;
        font-size: 21px;
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        min-width: 3ch;
        text-align: right;
      }

      .val span {
        grid-area: 1 / 1;
      }

      /* The readout stays dashed through the sweep — the needle is moving,
         but the instrument is not claiming a measurement — and flips to
         digits only when the first reading seats. */
      .dash {
        color: color-mix(in srgb, var(--slate) 38%, transparent);
        animation: dash var(--t) steps(1, end) infinite;
      }

      @keyframes dash {
        0% {
          opacity: 1;
        }
        53% {
          opacity: 0;
        }
        97%,
        100% {
          opacity: 1;
        }
      }

      .num {
        counter-reset: v var(--_n);
        animation: num var(--t) steps(1, end) infinite;
      }

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

      @keyframes num {
        0% {
          opacity: 0;
        }
        53% {
          opacity: 1;
        }
        97%,
        100% {
          opacity: 0;
        }
      }

      .state {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }

      .dot {
        width: 7px;
        height: 7px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--slate) 35%, transparent);
        animation: dot var(--t) steps(1, end) infinite;
      }

      @keyframes dot {
        0% {
          background: color-mix(in srgb, var(--slate) 35%, transparent);
        }
        53% {
          background: var(--olive);
        }
        97%,
        100% {
          background: color-mix(in srgb, var(--slate) 35%, transparent);
        }
      }

      .word {
        display: grid;
        color: color-mix(in srgb, var(--slate) 48%, transparent);
      }

      .word span {
        grid-area: 1 / 1;
      }

      .w-test {
        animation: w-test var(--t) steps(1, end) infinite;
      }

      @keyframes w-test {
        0% {
          opacity: 1;
        }
        53% {
          opacity: 0;
        }
        97%,
        100% {
          opacity: 1;
        }
      }

      .w-live {
        color: var(--olive);
        animation: w-live var(--t) steps(1, end) infinite;
      }

      @keyframes w-live {
        0% {
          opacity: 0;
        }
        53% {
          opacity: 1;
        }
        97%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The sweep is pure travel and goes. The needle steps straight to its
           readings, and SELF-TEST → LIVE still flips at the same beat, so the
           instrument still visibly comes online. */
        .gauge {
          animation:
            signal-rm calc(var(--t) * 1.7) steps(1, end) infinite,
            life calc(var(--t) * 1.7) ease infinite;
        }

        .dash,
        .num,
        .dot,
        .w-test,
        .w-live {
          animation-duration: calc(var(--t) * 1.7);
        }

        @keyframes signal-rm {
          0% {
            --_v: 0;
            --_n: 0;
          }
          53% {
            --_v: 58;
            --_n: 58;
          }
          68% {
            --_v: 61;
            --_n: 61;
          }
          82%,
          100% {
            --_v: 57;
            --_n: 57;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="gauge"
      role="img"
      aria-label="A gauge powering on: the needle sweeps its full range and back to a held zero as a self-test, then eases to its first live reading while the label flips from self-test to live"
    >
      <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="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="val">
          <span class="dash">––%</span>
          <span class="num"></span>
        </span>
        <span class="state">
          <span class="dot"></span>
          <span class="word">
            <span class="w-test">Self-test</span>
            <span class="w-live">Live</span>
          </span>
        </span>
      </div>
    </div>
  </body>
</html>

The variant family

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