Back to the library

Gauge — hysteresis latch

Variant: Hysteresis latch

The needle crosses the solid trip line at 80 twice and leaves the warning zone in between — and the lamp changes state exactly twice, not four times. That is hysteresis: trip at the solid line, release only below the dashed one at 64, and the sixteen-point gap between them is drawn on the dial because it is the design decision. All the wandering is ease-in-out, a measurement travelling and responding to nobody, while the lamp flips on steps(1, end) — a verdict, not a fade. Reach for it on alerting thresholds, autoscaling triggers, and battery cutoffs: anywhere a state that flapped with its own signal would train people to ignore 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" />
    <title>Gauge — hysteresis latch</title>
    <style>
      @property --_v {
        syntax: '<number>';
        inherits: true;
        initial-value: 46;
      }
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 46;
      }

      :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 — 6.4s: the needle crosses into the trip zone at ~18%, leaves
           it at ~29%, re-enters at ~40%, and falls through the release line
           at ~63% — and the lamp changes state exactly twice. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

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

      /* A load that wanders. Every move is ease-in-out — the needle is already
         on screen, travelling from one value to another, responding to nobody. */
      @keyframes signal {
        0%,
        6% {
          --_v: 46;
          --_n: 46;
          animation-timing-function: var(--ease-in-out);
        }
        12%,
        15% {
          --_v: 72;
          --_n: 72;
          animation-timing-function: var(--ease-in-out);
        }
        21%,
        26% {
          --_v: 84;
          --_n: 84;
          animation-timing-function: var(--ease-in-out);
        }
        33%,
        37% {
          --_v: 74;
          --_n: 74;
          animation-timing-function: var(--ease-in-out);
        }
        43%,
        48% {
          --_v: 88;
          --_n: 88;
          animation-timing-function: var(--ease-in-out);
        }
        56%,
        60% {
          --_v: 69;
          --_n: 69;
          animation-timing-function: var(--ease-in-out);
        }
        67%,
        72% {
          --_v: 57;
          --_n: 57;
          animation-timing-function: var(--ease-in-out);
        }
        79%,
        88% {
          --_v: 49;
          --_n: 49;
          animation-timing-function: var(--ease-in-out);
        }
        94%,
        100% {
          --_v: 46;
          --_n: 46;
        }
      }

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

      .zone {
        fill: none;
        stroke: color-mix(in srgb, var(--rust) 35%, transparent);
        stroke-width: 6;
      }

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

      .trip {
        stroke: var(--rust);
        stroke-width: 2;
        stroke-linecap: round;
      }

      /* The release mark, drawn dashed and lower than the trip mark: the gap
         between the two lines is the hysteresis, made visible on the dial. */
      .release {
        stroke: color-mix(in srgb, var(--slate) 62%, transparent);
        stroke-width: 2;
        stroke-dasharray: 3 2;
      }

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

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

      .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: var(--olive);
        animation: dot var(--t) steps(1, end) infinite;
      }

      /* The latch. It trips the moment the needle crosses the solid line and
         lets go only below the dashed one — steps(1, end), because a state is
         a verdict and a verdict does not fade in. */
      @keyframes dot {
        0% {
          background: var(--olive);
        }
        19% {
          background: var(--rust);
        }
        63.5%,
        100% {
          background: var(--olive);
        }
      }

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

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

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

      @keyframes w-steady {
        0% {
          opacity: 1;
        }
        19% {
          opacity: 0;
        }
        63.5%,
        100% {
          opacity: 1;
        }
      }

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

      @keyframes w-high {
        0% {
          opacity: 0;
        }
        19% {
          opacity: 1;
        }
        63.5%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The needle steps between readings with no travel; the lamp keeps
           latching on the same stretched clock — the two crossings are the
           content, and colour is how they are told. */
        .gauge {
          animation: signal-rm calc(var(--t) * 1.7) steps(1, end) infinite;
        }

        /* With the needle stepping, 84 lands at 21% rather than being crossed
           at ~18% — the lamp gets its own keyframes so the lamp trips at 21 and
           releases at 67, the frames its crossings actually land on. */
        .dot {
          animation: dot-rm calc(var(--t) * 1.7) steps(1, end) infinite;
        }

        .w-steady {
          animation: w-steady-rm calc(var(--t) * 1.7) steps(1, end) infinite;
        }

        .w-high {
          animation: w-high-rm calc(var(--t) * 1.7) steps(1, end) infinite;
        }

        @keyframes dot-rm {
          0% {
            background: var(--olive);
          }
          21% {
            background: var(--rust);
          }
          67%,
          100% {
            background: var(--olive);
          }
        }

        @keyframes w-steady-rm {
          0% {
            opacity: 1;
          }
          21% {
            opacity: 0;
          }
          67%,
          100% {
            opacity: 1;
          }
        }

        @keyframes w-high-rm {
          0% {
            opacity: 0;
          }
          21% {
            opacity: 1;
          }
          67%,
          100% {
            opacity: 0;
          }
        }

        @keyframes signal-rm {
          0% {
            --_v: 46;
            --_n: 46;
          }
          12% {
            --_v: 72;
            --_n: 72;
          }
          21% {
            --_v: 84;
            --_n: 84;
          }
          33% {
            --_v: 74;
            --_n: 74;
          }
          43% {
            --_v: 88;
            --_n: 88;
          }
          56% {
            --_v: 69;
            --_n: 69;
          }
          67% {
            --_v: 57;
            --_n: 57;
          }
          79% {
            --_v: 49;
            --_n: 49;
          }
          94%,
          100% {
            --_v: 46;
            --_n: 46;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="gauge"
      role="img"
      aria-label="A dial gauge with a trip line at 80 and a dashed release line at 64: the needle wanders in and out of the warning zone, but the state lamp trips once and releases once"
    >
      <svg viewBox="0 0 220 118" aria-hidden="true">
        <path class="zone" d="M 169.9 60.5 A 74 74 0 0 1 184 104" />
        <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="trip" x1="169.9" y1="60.5" x2="163.4" y2="65.2" />
        <line class="release" x1="141.5" y1="37" x2="138.1" y2="44.3" />
        <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="state">
          <span class="dot"></span>
          <span class="word">
            <span class="w-steady">Steady</span>
            <span class="w-high">High</span>
          </span>
        </span>
      </div>
    </div>
  </body>
</html>

The variant family

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