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