Coupling — anticipation and follow-through
Variant: Anticipation and follow-through
The two oldest principles in animation, put as the difference between two lanes rather than described. Both bodies cover the same 184px and both stop on the same frame; the wound-up one spends 102ms going backwards first and has to leave harder to make that up, which is the entire trade — anticipation buys legibility with time you then earn back in speed. Its lean and its travel are one keyframe segment on one curve, so the body tips back while it is accelerating and forward while it is stopping with no second timeline to keep aligned, and the follow-through falls out of the same fact: something that pitched over its own stop has to rock back to upright, three times, each about 40% of the last. Reach for the wound-up version once on a page — a send, a submit, a thing being thrown — and never on a control pressed forty times a day, where a wind-up is latency you added on purpose.
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 — anticipation and follow-through</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);
/* The launch. Both lanes cover the same ground in the same window, so
the wound-up one has to leave harder to make up the wind-up it spent. */
--ease-accent: cubic-bezier(0.32, 0.72, 0, 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: 240px;
height: 150px;
}
.rig {
position: absolute;
inset: 0;
animation: rig-cycle var(--t) linear infinite;
}
.lane {
position: absolute;
left: 0;
right: 0;
height: 66px;
}
.lane-a {
top: 4px;
}
.lane-b {
top: 84px;
}
.cap {
position: absolute;
top: 0;
left: 0;
font-size: 11px;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
.rail {
position: absolute;
top: 60px;
left: 0;
right: 4px;
height: 2px;
background: var(--oat);
}
/* Rest position and the stop. Both lanes share the same two marks, which
is the only reason "left later, arrived together" is readable at all. */
.home {
position: absolute;
top: 54px;
left: 18px;
width: 1.5px;
height: 8px;
background: var(--oat);
}
.gate {
position: absolute;
top: 42px;
right: 4px;
z-index: 2;
width: 2px;
height: 20px;
background: var(--clay-d);
}
/* The wind-up drawn rather than merely felt: a tick at the far end of the
12px the body gives up before it goes anywhere. */
.wind {
position: absolute;
top: 54px;
left: 6px;
width: 1.5px;
height: 8px;
background: var(--clay);
opacity: 0;
animation: wind-tick var(--t) infinite;
}
.body {
position: absolute;
top: 26px;
left: 18px;
width: 32px;
height: 34px;
border-radius: calc(7px * var(--round));
background: var(--paper);
border: 1.5px solid var(--oat);
/* A body pivots about the surface it is standing on, never its middle. */
transform-origin: center bottom;
}
/* The leading face. It is what makes a five-degree lean legible on a
34px box — the eye reads the bar tipping, not the corner moving. */
.body::after {
content: '';
position: absolute;
top: 6px;
bottom: 6px;
right: -1.5px;
width: 3px;
border-radius: 2px;
background: var(--clay);
}
.b-direct {
animation: run-direct var(--t) infinite;
}
.b-wound {
animation: run-wound var(--t) infinite;
}
/* Straight from rest to the stop: one ease-out, 184px, no wind-up and
nothing left over at the end. */
@keyframes run-direct {
0%,
11% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
25%,
91% {
transform: translateX(184px);
}
92%,
100% {
transform: translateX(0);
}
}
/* The same 184px in the same window, bought differently. The wind-up runs
ease-in-out because it decelerates into its own turn, and the launch and
the whole flight are one segment on one curve — so the lean crosses from
back to forward exactly where the acceleration does, for free. Landing
is 25% in both lanes: the wind-up costs 102ms at the start and gets it
back in speed, which is the only reason it is ever worth spending. */
@keyframes run-wound {
0%,
8% {
transform: translateX(0) rotate(0deg);
animation-timing-function: var(--ease-in-out);
}
14% {
transform: translateX(-12px) rotate(-7deg);
animation-timing-function: var(--ease-accent);
}
25% {
transform: translateX(184px) rotate(5deg);
animation-timing-function: var(--ease-out);
}
29% {
transform: translateX(184px) rotate(-2.4deg);
animation-timing-function: var(--ease-in-out);
}
32.5% {
transform: translateX(184px) rotate(1deg);
animation-timing-function: var(--ease-in-out);
}
36%,
91% {
transform: translateX(184px) rotate(0deg);
}
92%,
100% {
transform: translateX(0) rotate(0deg);
}
}
@keyframes wind-tick {
0%,
9% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
13%,
26% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
34%,
100% {
opacity: 0;
}
}
/* Both bodies are re-seated at 92%, well inside the window the rig spends
at zero — the loop never shows either of them rewinding. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
86% {
opacity: 1;
}
90%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* All travel and all rotation go. What survives is the shape of the
two timings: the direct lane simply brightens, and the wound one dips
below its rest state first. Anticipation is legible as a dip whether
or not anything is allowed to move. */
.rig {
animation: none;
opacity: 1;
}
.body {
animation: none !important;
transform: none !important;
}
.wind {
animation: none;
opacity: 0.5;
}
.b-direct {
animation: rest-direct calc(var(--t) * 1.6) linear infinite !important;
}
.b-wound {
animation: rest-wound calc(var(--t) * 1.6) linear infinite !important;
}
@keyframes rest-direct {
0%,
20% {
opacity: 0.4;
}
34%,
72% {
opacity: 1;
}
88%,
100% {
opacity: 0.4;
}
}
@keyframes rest-wound {
0%,
14% {
opacity: 0.4;
}
20% {
opacity: 0.18;
}
34%,
72% {
opacity: 1;
}
88%,
100% {
opacity: 0.4;
}
}
}
</style>
</head>
<body>
<div
class="scene"
role="img"
aria-label="Two lanes racing to the same stop: one travels straight there, the other winds backwards first, launches harder, arrives on the same frame and rocks forward before settling"
>
<div class="rig">
<div class="lane lane-a">
<span class="cap">direct</span>
<span class="rail"></span>
<span class="home"></span>
<span class="gate"></span>
<span class="body b-direct"></span>
</div>
<div class="lane lane-b">
<span class="cap">wound up</span>
<span class="rail"></span>
<span class="home"></span>
<span class="wind"></span>
<span class="gate"></span>
<span class="body b-wound"></span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.