Gauge — hard cap
Variant: Hard cap
Usage arrives in bursts and every burst decelerates in, the way every arrival in this library does — except the last. The final move accelerates, because demand did not slow near the limit; the pin is what ends the journey, and the needle gives back a 1.3-unit recoil that a spring would have turned into a bounce. Nothing ever crosses 100: overshoot at a hard boundary claims the boundary gave, and a quota that gives is not a quota. The pin, the readout, and the label latch rust on the frame of contact and stay latched for as long as the needle presses the stop. Use it for rate limits, storage quotas, and budget caps — states the user needs to believe are walls.
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 — hard cap</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 — 6s: four eased arrivals, one collision, a long latched
hold, and a reset under the dark beat at the tail. */
--cycle: 6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* The collision: the one move in this file that accelerates into its
end, because the needle does not arrive at 100 — the stop is what
ends the journey. */
--ease-accent: cubic-bezier(0.5, 0, 0.82, 0.55);
}
* {
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;
}
/* Usage lands in bursts, and each burst decelerates in — an arrival.
The last move is the exception: demand did not slow down near the
limit, so the curve accelerates and the wall does the stopping,
with a 1.3-unit recoil the pin gives back. */
@keyframes signal {
0%,
6% {
--_v: 0;
--_n: 0;
animation-timing-function: var(--ease-out);
}
11%,
17% {
--_v: 24;
--_n: 24;
animation-timing-function: var(--ease-out);
}
22%,
34% {
--_v: 46;
--_n: 46;
animation-timing-function: var(--ease-out);
}
39%,
45% {
--_v: 71;
--_n: 71;
animation-timing-function: var(--ease-out);
}
50%,
59% {
--_v: 88;
--_n: 88;
animation-timing-function: var(--ease-accent);
}
62.5% {
--_v: 100;
--_n: 100;
animation-timing-function: var(--ease-out);
}
63.7% {
--_v: 98.7;
--_n: 100;
animation-timing-function: var(--ease-out);
}
65.5%,
95% {
--_v: 100;
--_n: 100;
}
96.5%,
100% {
--_v: 0;
--_n: 0;
}
}
/* Arrive, hold, dim once capped long enough to be read, and reset the
needle only inside the dark window — no visible rewind. */
@keyframes life {
0%,
0.5% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
3.5%,
84% {
opacity: 1;
}
88%,
91% {
opacity: 0.6;
}
95%,
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);
}
/* The end stop: a physical pin parked on the needle tip's swept path,
so the collision is contact rather than alignment. It latches rust on
contact and stays rust for as long as the needle is against it — a
limit that unlit itself while still binding would be lying. */
.pin {
stroke: var(--slate);
stroke-width: 3.5;
stroke-linecap: round;
animation: pin var(--t) steps(1, end) infinite;
}
@keyframes pin {
0% {
stroke: var(--slate);
}
62.5% {
stroke: var(--rust);
}
95%,
100% {
stroke: 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: 4ch;
text-align: right;
counter-reset: v var(--_n);
animation: num var(--t) steps(1, end) infinite;
}
.num::after {
content: counter(v) '%';
}
@keyframes num {
0% {
color: var(--slate);
}
62.5% {
color: var(--rust);
}
95%,
100% {
color: var(--slate);
}
}
.word {
display: grid;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.word span {
grid-area: 1 / 1;
}
.w-fill {
color: color-mix(in srgb, var(--slate) 48%, transparent);
animation: w-fill var(--t) steps(1, end) infinite;
}
@keyframes w-fill {
0% {
opacity: 1;
}
62.5% {
opacity: 0;
}
95%,
100% {
opacity: 1;
}
}
.w-cap {
color: var(--rust);
animation: w-cap var(--t) steps(1, end) infinite;
}
@keyframes w-cap {
0% {
opacity: 0;
}
62.5% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The needle steps between values — the recoil goes, since it is
pure physics — while the rust latch and the readout keep telling
the story on a stretched clock. */
.gauge {
animation:
signal-rm calc(var(--t) * 1.6) steps(1, end) infinite,
life calc(var(--t) * 1.6) ease infinite;
}
.pin,
.num,
.w-fill,
.w-cap {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes signal-rm {
0% {
--_v: 0;
--_n: 0;
}
11% {
--_v: 24;
--_n: 24;
}
22% {
--_v: 46;
--_n: 46;
}
39% {
--_v: 71;
--_n: 71;
}
50% {
--_v: 88;
--_n: 88;
}
62.5% {
--_v: 100;
--_n: 100;
}
96.5%,
100% {
--_v: 0;
--_n: 0;
}
}
}
</style>
</head>
<body>
<div
class="gauge"
role="img"
aria-label="A quota gauge filling in bursts toward a physical end stop: the needle accelerates into the pin at 100, recoils a hair, and the readout latches rust at the limit"
>
<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="pin" x1="173" y1="97" x2="173" y2="111" />
<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="word">
<span class="w-fill">Filling</span>
<span class="w-cap">At limit</span>
</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.