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