Momentum — flick and friction
Variant: Flick and friction
The base case for anything thrown rather than sent: the same puck given the same 30px push twice, once over 150ms and once over 38ms, coasting 56px and 134px off it. Distance out of a flick is set by release velocity and nothing else, which is why a fling cannot be authored as a duration to a destination — you do not know the destination until the finger has already left. The coast is an expo-out curve because that is the integral of v₀·e^(−kt), the path a body actually traces against viscous drag, and both lanes share it: friction is a property of the surface, not of the throw. The reason the two lanes stop on the same frame is the other half of the lesson — decay is exponential, so quadrupling the release speed quadruples the travel and leaves the settle time where it was.
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>Momentum — flick and friction</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: 4.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Friction decay. An expo-out curve is v0·e^(−kt) integrated, which is
what a body coasting against viscous drag actually traces — the one
characterful curve in the file, and both lanes share it. */
--ease-accent: cubic-bezier(0.16, 1, 0.3, 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: 12px;
animation: rig-cycle var(--t) linear infinite;
}
.cap {
font-size: 11px;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--slate) 52%, transparent);
}
.foot {
opacity: 0;
animation: foot-in var(--t) linear infinite;
}
.lane {
display: grid;
gap: 5px;
}
.tag {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 11px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--slate) 62%, transparent);
}
/* The distance lands on the frame the puck stops, so the two numbers
arrive in throw order and the reader is never asked to hold one in
their head while the other is still resolving. */
.dist {
color: var(--clay-d);
opacity: 0;
}
.l1 .dist {
animation: dist-soft var(--t) steps(1, end) infinite;
}
.l2 .dist {
animation: dist-hard 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);
}
.puck {
position: absolute;
left: 0;
bottom: 2px;
width: 18px;
height: 18px;
border-radius: calc(5px * var(--round));
background: var(--clay);
}
/* The hand is the input, so it has to be visible while it is the thing
doing work and gone the instant it stops being. Both lanes push the
puck exactly 30px; only the time they take over it differs. */
.hand {
position: absolute;
left: -3px;
bottom: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 13%, transparent);
border: 1.5px solid color-mix(in srgb, var(--slate) 30%, transparent);
opacity: 0;
}
.l1 .puck {
animation: throw-soft var(--t) infinite;
}
.l1 .hand {
animation:
push-soft var(--t) infinite,
grip-soft var(--t) infinite;
}
.l2 .puck {
animation: throw-hard var(--t) infinite;
}
.l2 .hand {
animation:
push-hard var(--t) infinite,
grip-hard var(--t) infinite;
}
/* 150ms to cover 30px — 0.20 px/ms at release — then 26px of coast.
The push is linear because a hand crossing a short distance is not
easing; the coast is where all the character lives. */
@keyframes throw-soft {
0%,
8% {
transform: translateX(0);
animation-timing-function: linear;
}
11.26% {
transform: translateX(30px);
animation-timing-function: var(--ease-accent);
}
24.74%,
94% {
transform: translateX(56px);
}
94.01%,
100% {
transform: translateX(0);
}
}
/* The same 30px in 38ms — 0.79 px/ms, near enough four times the speed —
and the coast is four times as long. Distance out of a flick scales
with release velocity; the time it takes to stop does not. */
@keyframes throw-hard {
0%,
34% {
transform: translateX(0);
animation-timing-function: linear;
}
34.83% {
transform: translateX(30px);
animation-timing-function: var(--ease-accent);
}
48.31%,
94% {
transform: translateX(134px);
}
94.01%,
100% {
transform: translateX(0);
}
}
@keyframes push-soft {
0%,
8% {
transform: translateX(0);
animation-timing-function: linear;
}
11.26%,
100% {
transform: translateX(30px);
}
}
@keyframes push-hard {
0%,
34% {
transform: translateX(0);
animation-timing-function: linear;
}
34.83%,
100% {
transform: translateX(30px);
}
}
/* Contact ends on the release frame, not a beat after it. A hand still
drawn on the puck while the puck is coasting claims the coast is being
driven, which is the one thing this piece is arguing against.
Both hands rest on the puck for the same 138ms before pushing. Without
that beat the fast lane's hand is on screen for 38ms — two frames — and
a viewer sees the second puck simply travel further for no reason. The
rest is what makes the push the only thing that differs. */
@keyframes grip-soft {
0%,
3% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
5%,
11.26% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
13.5%,
100% {
opacity: 0;
}
}
@keyframes grip-hard {
0%,
29% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
31%,
34.83% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
37%,
100% {
opacity: 0;
}
}
@keyframes dist-soft {
0%,
24.73% {
opacity: 0;
}
24.74%,
100% {
opacity: 1;
}
}
@keyframes dist-hard {
0%,
48.3% {
opacity: 0;
}
48.31%,
100% {
opacity: 1;
}
}
@keyframes foot-in {
0%,
50% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
54%,
100% {
opacity: 1;
}
}
/* Both pucks re-arm at 94%, inside the dark window, so the reset is never
a visible run backwards along the track they just travelled. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
87% {
opacity: 1;
}
93%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* Travel goes; the claim survives as two pucks parked at the distances
their release speeds bought, announcing themselves in throw order. */
.rig {
animation: none;
opacity: 1;
}
.hand {
animation: none !important;
opacity: 0;
}
.l1 .puck {
animation: none;
transform: translateX(56px);
}
.l2 .puck {
animation: none;
transform: translateX(134px);
}
.dist {
animation: rest-cue calc(var(--t) * 0.6) ease infinite !important;
}
.l2 .dist {
animation-delay: calc(0.34s / var(--speed)) !important;
}
.foot {
animation: none;
opacity: 1;
}
@keyframes rest-cue {
0%,
10% {
opacity: 0.25;
}
26%,
64% {
opacity: 1;
}
82%,
100% {
opacity: 0.25;
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="The same puck pushed the same 30 pixels twice, once slowly and once four times as fast: the slow release coasts to 56 pixels, the fast one to 134, and both come to rest in the same 620 milliseconds"
>
<p class="cap">same push · two release speeds</p>
<div class="lane l1">
<p class="tag"><span>0.20 px/ms</span><span class="dist">56px</span></p>
<div class="track"><span class="puck"></span><span class="hand"></span></div>
</div>
<div class="lane l2">
<p class="tag"><span>0.79 px/ms</span><span class="dist">134px</span></p>
<div class="track"><span class="puck"></span><span class="hand"></span></div>
</div>
<p class="cap foot">both stop after 620ms</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.