Momentum — fling into a detent
Variant: Fling into a detent
A picker flung twice, landing once from each side: friction runs out 7px short of a detent on the first throw and 9px past one on the second, and both times the detent finishes the job as part of the same arrival. The naive build is a fling that settles and then a snap animation that fires — the user sees the drum stop and start again, which is one event reported as two. Getting one arrival instead comes down to the seam between the coast and the capture, and to two curves that are wrong for it: an ease-out capture re-accelerates the drum on that frame, an ease-in-out one puts a dead stop there. The window is 99px with a 1.5px border so the content box is exactly three 32px rows, because a picker whose window is not a whole number of rows can never rest with a value centred.
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 — fling into a detent</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.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Friction: the coast after release, and the one characterful curve in
the file. It deliberately does NOT settle — it hands the last of its
speed to the capture below, which is the whole build. */
--ease-accent: cubic-bezier(0.12, 0.62, 0.42, 0.9);
/* The detent's pull. It cannot be --ease-out, whose fast start would
re-accelerate the drum on the seam frame and turn one arrival into a
coast plus a snap; and it cannot be --ease-in-out, whose long slow
start puts a dead stop there instead. This curve leaves at roughly the
speed the coast arrived with and lands at rest. */
--_capture: cubic-bezier(0.4, 0.15, 0.25, 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: 176px;
display: grid;
gap: 11px;
justify-items: center;
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;
}
/* 99px tall with a 1.5px border, so the content box is exactly 96 — three
32px detents. A picker whose window is not a whole number of rows can
never rest with a row centred. */
.picker {
position: relative;
width: 132px;
height: 99px;
overflow: hidden;
border: 1.5px solid var(--oat);
border-radius: calc(11px * var(--round));
background: var(--paper);
}
.band {
position: absolute;
left: 0;
right: 0;
top: 32px;
height: 32px;
background: color-mix(in srgb, var(--clay) 10%, transparent);
border-top: 1.5px solid color-mix(in srgb, var(--oat) 85%, transparent);
border-bottom: 1.5px solid color-mix(in srgb, var(--oat) 85%, transparent);
}
/* The fade is on the window rather than on the column, so the rim stays
put while the type rolls under it — masking the column instead would
drag the soft edges along with the travel. The card's own border is
outside the mask and stays crisp. */
.window {
position: absolute;
inset: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(
transparent,
var(--slate) 24%,
var(--slate) 76%,
transparent
);
mask-image: linear-gradient(transparent, var(--slate) 24%, var(--slate) 76%, transparent);
}
.column {
display: grid;
grid-auto-rows: 32px;
animation: fling var(--t) infinite;
}
.item {
display: grid;
place-items: center;
font-size: 14px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
/* Colour is the only thing that marks the selection. A weight change would
re-measure the glyphs and shift the value sideways on the frame it
locks, which is the one frame the eye is on it. */
.item:nth-child(2) {
animation: sel-a var(--t) infinite;
}
.item:nth-child(5) {
animation: sel-b var(--t) infinite;
}
.item:nth-child(6) {
animation: sel-c var(--t) infinite;
}
.hand {
position: absolute;
right: 7px;
top: 37px;
width: 22px;
height: 22px;
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;
animation:
hand-move var(--t) infinite,
hand-grip var(--t) infinite;
}
/* Two flings, and the difference between them is which side of a detent
friction alone would have run out on. The first stops 7px short and the
detent draws it the rest of the way in; the second sails 9px past and
the detent draws it back. Both are one arrival with no stop in it —
that is the whole distinction from a fling that settles and is then
followed by a separate snap animation. */
@keyframes fling {
0%,
7% {
transform: translateY(0);
animation-timing-function: linear;
}
7.9% {
transform: translateY(-14px);
animation-timing-function: var(--ease-accent);
}
20.4% {
transform: translateY(-89px);
animation-timing-function: var(--_capture);
}
25.1%,
37% {
transform: translateY(-96px);
animation-timing-function: linear;
}
37.8% {
transform: translateY(-106px);
animation-timing-function: var(--ease-accent);
}
48.6% {
transform: translateY(-137px);
animation-timing-function: var(--_capture);
}
54%,
95% {
transform: translateY(-128px);
}
95.01%,
100% {
transform: translateY(0);
}
}
@keyframes hand-move {
0%,
7% {
transform: translateY(0);
animation-timing-function: linear;
}
7.9%,
20% {
transform: translateY(-14px);
}
20.01%,
37% {
transform: translateY(0);
animation-timing-function: linear;
}
37.8%,
100% {
transform: translateY(-10px);
}
}
/* Contact ends on the release frame. Everything after it is friction and
the detent, and a hand still drawn on the drum would claim otherwise. */
@keyframes hand-grip {
0%,
3.5% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
5%,
7.9% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
10%,
34% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
35.5%,
37.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
40%,
100% {
opacity: 0;
}
}
/* Each value lights on the frame the drum stops and goes out on the frame
it starts moving again — a picker has no in-between state, and fading
the selection across the travel would invent one. */
@keyframes sel-a {
0%,
7% {
color: var(--clay-d);
}
7.01%,
95% {
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
95.01%,
100% {
color: var(--clay-d);
}
}
@keyframes sel-b {
0%,
25.09% {
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
25.1%,
37% {
color: var(--clay-d);
}
37.01%,
100% {
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
}
@keyframes sel-c {
0%,
53.99% {
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
54%,
95% {
color: var(--clay-d);
}
95.01%,
100% {
color: color-mix(in srgb, var(--slate) 40%, transparent);
}
}
@keyframes foot-in {
0%,
26% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
28.5%,
100% {
opacity: 1;
}
}
/* The drum re-arms at 95%, inside the dark window, so it never spins
backwards through four values to get home. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
88% {
opacity: 1;
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The travel goes and the detent stays: the drum is parked on a value,
and the band breathes so the control still reads as live. */
.rig {
animation: none;
opacity: 1;
}
.column {
animation: none;
transform: translateY(-96px);
}
.hand {
animation: none;
opacity: 0;
}
.item:nth-child(2),
.item:nth-child(6) {
animation: none;
}
.item:nth-child(5) {
animation: none;
color: var(--clay-d);
}
.band {
animation: rest-band calc(var(--t) * 0.8) ease infinite;
}
.foot {
animation: none;
opacity: 1;
}
@keyframes rest-band {
0%,
8% {
opacity: 0.5;
}
40%,
60% {
opacity: 1;
}
92%,
100% {
opacity: 0.5;
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A time picker flicked twice: friction runs the drum out just short of one detent and just past the next, and each time the detent draws it the rest of the way in as part of the same arrival"
>
<p class="cap">fling · friction · detent</p>
<div class="picker">
<span class="band"></span>
<div class="window">
<div class="column">
<span class="item">09:00</span>
<span class="item">09:15</span>
<span class="item">09:30</span>
<span class="item">09:45</span>
<span class="item">10:00</span>
<span class="item">10:15</span>
<span class="item">10:30</span>
<span class="item">10:45</span>
</div>
</div>
<span class="hand"></span>
</div>
<p class="cap foot">the last 7px is the detent</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.