Weight — three dampings
Variant: Damping compared
One stiffness, one target, three damping ratios released on the same frame — the piece to open when someone asks why their spring "feels wrong". Critical damping is the fastest approach that never crosses the line, and it settles in 0.71s. The bouncy one at ζ 0.25 crosses the target in under a tenth of a second and is still ringing at 0.83s. The overdamped one never misbehaves and takes 1.22s, most of it spent covering the last four pixels. Each settle time appears on the frame its puck stops, so the numbers land in finishing order and the argument makes itself. All three pucks are the same colour deliberately: damping is the only variable, and a second one would need discounting.
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>Weight — three dampings</title>
<style>
: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 — every duration in the document runs off --t. */
--cycle: 3.4s;
--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);
}
.rig {
width: 200px;
display: grid;
gap: 13px;
animation: rig-cycle var(--t) linear infinite;
}
.cap {
font-size: 11px;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--slate) 52%, transparent);
}
.lane {
display: grid;
gap: 5px;
}
.tag {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 11px;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--slate) 62%, transparent);
}
/* The settle time appears on the frame the puck is done moving, so the
three numbers arrive in the order the pucks actually finish — which is
the argument the piece is making. */
.settled {
font-variant-numeric: tabular-nums;
color: var(--clay-d);
opacity: 0;
}
.l1 .settled {
animation: settle-slow var(--t) steps(1, end) infinite;
}
.l2 .settled {
animation: settle-fast var(--t) steps(1, end) infinite;
}
.l3 .settled {
animation: settle-mid var(--t) steps(1, end) infinite;
}
.track {
position: relative;
height: 22px;
}
.track::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--oat);
}
/* The target, at the puck's leading edge rather than its centre — the eye
reads arrival off an edge meeting a line, not off a guessed midpoint. */
.mark {
position: absolute;
left: 147.5px;
bottom: 0;
width: 1.5px;
height: 22px;
background: color-mix(in srgb, var(--slate) 26%, transparent);
}
.puck {
position: absolute;
left: 0;
bottom: 2px;
width: 18px;
height: 18px;
border-radius: calc(5px * var(--round));
background: var(--clay);
}
/* All three pucks are the same colour on purpose. The only variable in
the piece is damping, so anything else that differed would be a second
variable the reader has to discount. */
.l1 .puck {
animation: over var(--t) infinite;
}
.l2 .puck {
animation: critical var(--t) infinite;
}
.l3 .puck {
animation: under var(--t) infinite;
}
/* Heavily overdamped: no overshoot and no ring, but the last 4px take
longer than the first 84 did. This is the regime a too-cautious spring
lands in — it never misbehaves and it never feels finished. */
@keyframes over {
0%,
10% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
17% {
transform: translateX(84px);
animation-timing-function: var(--ease-out);
}
23% {
transform: translateX(118px);
animation-timing-function: var(--ease-out);
}
30% {
transform: translateX(138px);
animation-timing-function: var(--ease-out);
}
37% {
transform: translateX(146px);
animation-timing-function: var(--ease-out);
}
46%,
93% {
transform: translateX(150px);
}
94%,
100% {
transform: translateX(0);
}
}
/* Critical damping: the fastest approach that never crosses the target.
There is no faster arrival without overshoot — that is the definition,
not a tuning choice. */
@keyframes critical {
0%,
10% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
17% {
transform: translateX(118px);
animation-timing-function: var(--ease-out);
}
22% {
transform: translateX(141px);
animation-timing-function: var(--ease-out);
}
26% {
transform: translateX(148.5px);
animation-timing-function: var(--ease-out);
}
31%,
93% {
transform: translateX(150px);
}
94%,
100% {
transform: translateX(0);
}
}
/* Underdamped, ζ ≈ 0.25: successive excursions shrink by about a fifth —
28px, 5.5px, 1.1px — on a fixed 204ms half-period, which is what makes
a decaying oscillation read as one object rather than three nudges. The
reversals are ease-in-out because a body turning around decelerates
into the turn; only the release is ease-out. */
@keyframes under {
0%,
10% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
16.5% {
transform: translateX(178px);
animation-timing-function: var(--ease-in-out);
}
22.5% {
transform: translateX(144.5px);
animation-timing-function: var(--ease-in-out);
}
28.5% {
transform: translateX(151.1px);
animation-timing-function: var(--ease-in-out);
}
34.5%,
93% {
transform: translateX(150px);
}
94%,
100% {
transform: translateX(0);
}
}
@keyframes settle-slow {
0%,
45.9% {
opacity: 0;
}
46%,
100% {
opacity: 1;
}
}
@keyframes settle-fast {
0%,
30.9% {
opacity: 0;
}
31%,
100% {
opacity: 1;
}
}
@keyframes settle-mid {
0%,
34.4% {
opacity: 0;
}
34.5%,
100% {
opacity: 1;
}
}
/* Pucks are re-armed at 94%, inside the dark window, so the reset is never
a visible rewind against the direction of travel. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
86% {
opacity: 1;
}
92%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* Travel goes; the ordering — the whole lesson — survives as three
numbers still arriving in settle order. */
.rig {
animation: none;
opacity: 1;
}
.puck {
animation: none !important;
transform: translateX(150px);
}
.settled {
animation: rest-cue calc(var(--t) * 1.6) ease infinite !important;
}
.l1 .settled {
animation-delay: calc(0.3s / var(--speed)) !important;
}
.l2 .settled {
animation-delay: 0s !important;
}
.l3 .settled {
animation-delay: calc(0.12s / var(--speed)) !important;
}
@keyframes rest-cue {
0%,
8% {
opacity: 0;
}
18%,
70% {
opacity: 1;
}
84%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="Three pucks released together toward the same target with different damping: overdamped settles last, critical settles first, underdamped overshoots and rings"
>
<p class="cap">one stiffness · three dampings</p>
<div class="lane l1">
<p class="tag"><span>ζ 1.8 · overdamped</span><span class="settled">1.22s</span></p>
<div class="track"><span class="mark"></span><span class="puck"></span></div>
</div>
<div class="lane l2">
<p class="tag"><span>ζ 1.0 · critical</span><span class="settled">0.71s</span></p>
<div class="track"><span class="mark"></span><span class="puck"></span></div>
</div>
<div class="lane l3">
<p class="tag"><span>ζ 0.25 · bouncy</span><span class="settled">0.83s</span></p>
<div class="track"><span class="mark"></span><span class="puck"></span></div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.