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