Coupling — momentum through contact
Variant: Momentum through contact
A block slides into three touching blocks, stops dead, and the far one leaves — while the two in the middle are never given a keyframe at all, which is the only honest way to draw a body that did not move. Two decisions carry it. The struck block departs six milliseconds after the striker stops, inside the same frame at any refresh rate anyone owns: put a tenth of a second between them and the transfer stops being one event and becomes two animations that happen to be adjacent, which is the version almost everyone ships. And all three contact faces light on the same frame rather than in sequence, since a shock front does not take 100ms to cross three touching bodies — only the decay is staggered, longest at the far end, so the light reads as having travelled without any of it arriving late. The striker coasts linear because its speed is the quantity about to be handed over, and both 48px spans are standing rules rather than timed ones, so the equal rate is something you can hold against a ruler.
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>Coupling — momentum through contact</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: 3s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 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);
}
.scene {
position: relative;
width: 238px;
height: 112px;
}
.rail {
position: absolute;
top: 74px;
left: 0;
right: 0;
height: 2px;
background: var(--oat);
}
.rig {
position: absolute;
inset: 0;
animation: rig-cycle var(--t) linear infinite;
}
.puck {
position: absolute;
top: 54px;
width: 20px;
height: 20px;
border-radius: calc(4px * var(--round));
background: var(--paper);
border: 1.5px solid var(--oat);
}
.striker {
left: 6px;
background: var(--clay);
border-color: var(--clay);
animation: strike var(--t) infinite;
}
/* Two bodies that are never given an animation at all. Everything the
piece claims about them has to be true of an element with no keyframes
on it, which is the only honest way to draw "these did not move". */
.p1 {
left: 74px;
}
.p2 {
left: 95px;
}
.p3 {
left: 116px;
animation: depart var(--t) infinite;
}
/* The contact faces. All three light on the same frame — a shock front
does not take 100ms to cross three touching bodies — and it is only the
decay that is staggered, longest at the far end, so the light reads as
having travelled without any of it arriving late. */
.face {
position: absolute;
top: 54px;
width: 3px;
height: 20px;
border-radius: 2px;
background: var(--clay-d);
opacity: 0;
}
.f1 {
left: 72.5px;
animation: pass-1 var(--t) infinite;
}
.f2 {
left: 93px;
animation: pass-2 var(--t) infinite;
}
.f3 {
left: 114px;
animation: pass-3 var(--t) infinite;
}
/* Two spans of exactly 48px, drawn once and never animated: what the
striker covered, and what the struck body covers before friction takes
over. Equal rate is the claim, and a claim you can hold a ruler to is
worth more than one the timing merely implies. */
.span {
position: absolute;
top: 88px;
width: 48px;
height: 1.5px;
background: var(--oat);
}
.span::before,
.span::after {
content: '';
position: absolute;
top: -6px;
width: 1.5px;
height: 7px;
background: var(--oat);
}
.span::before {
left: 0;
}
.span::after {
right: 0;
}
.span-a {
left: 6px;
}
.span-b {
left: 116px;
}
.tick {
position: absolute;
top: 94px;
width: 48px;
text-align: center;
font-size: 11px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
.tick-a {
left: 6px;
}
.tick-b {
left: 116px;
}
/* Linear, and nothing else would do: the striker is coasting, and its
speed is the quantity about to be handed over. Ease it and the number
being transferred changes every frame. */
@keyframes strike {
0%,
6% {
transform: translateX(0);
animation-timing-function: linear;
}
18% {
transform: translateX(48px);
animation-timing-function: var(--ease-out);
}
19.2%,
91% {
transform: translateX(49.5px);
}
92%,
100% {
transform: translateX(0);
}
}
/* Leaves at 18.2% — six milliseconds after the striker stopped, which is
inside the same frame at any refresh rate anyone owns. It covers its
first 48px in the same 12% of the clock the striker spent, so the two
rates are identical, and only then does friction round it off. */
@keyframes depart {
0%,
18.2% {
transform: translateX(0);
animation-timing-function: linear;
}
30.2% {
transform: translateX(48px);
animation-timing-function: var(--ease-out);
}
39%,
91% {
transform: translateX(66px);
}
92%,
100% {
transform: translateX(0);
}
}
@keyframes pass-1 {
0%,
17.6% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
18.2% {
opacity: 0.95;
animation-timing-function: var(--ease-out);
}
20.2%,
100% {
opacity: 0;
}
}
@keyframes pass-2 {
0%,
17.6% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
18.2% {
opacity: 0.95;
animation-timing-function: var(--ease-out);
}
21%,
100% {
opacity: 0;
}
}
@keyframes pass-3 {
0%,
17.6% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
18.2% {
opacity: 0.95;
animation-timing-function: var(--ease-out);
}
21.8%,
100% {
opacity: 0;
}
}
/* Both movers are re-seated at 92%, inside the window the rig spends at
zero, so the strike never plays in reverse. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
86% {
opacity: 1;
}
90%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* All travel goes, and the piece survives it better than most: what is
left running is the subject itself — force crossing three bodies that
were never going to move in the first place. */
.rig {
animation: none;
opacity: 1;
}
.striker,
.p3 {
animation: none;
transform: none;
}
.face {
animation: rest-pass calc(var(--t) * 1.6) ease infinite !important;
}
.f2 {
animation-delay: calc(0.11s / var(--speed)) !important;
}
.f3 {
animation-delay: calc(0.22s / var(--speed)) !important;
}
@keyframes rest-pass {
0%,
8% {
opacity: 0;
}
16%,
26% {
opacity: 0.9;
}
38%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="scene"
role="img"
aria-label="A block slides into a row of three touching blocks and stops dead; the two in the middle never move, and the far one leaves at the same speed the striker arrived with"
>
<div class="rail"></div>
<span class="span span-a"></span>
<span class="span span-b"></span>
<span class="tick tick-a">48px</span>
<span class="tick tick-b">48px</span>
<div class="rig">
<span class="puck striker"></span>
<span class="puck p1"></span>
<span class="puck p2"></span>
<span class="puck p3"></span>
<span class="face f1"></span>
<span class="face f2"></span>
<span class="face f3"></span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.