Momentum — past the threshold
Variant: Past the threshold
Swipe-to-dismiss done on velocity rather than on distance, and the reason to bother is the case almost every implementation gets wrong: a short fast flick. Both drags here cover exactly the same 38px — one hand element makes both, so the distance is provably the control — and only the one released at 0.76px/ms leaves. Thresholding on travel alone rejects that flick and the gesture feels broken in a way users describe as the card being sticky. The two outcomes are shaped differently on purpose: the held card relaxes home with no overshoot, because a band returns to its own length and does not carry content past rest, while the dismissed one keeps roughly two thirds of its release speed all the way out. A dismissal that decelerates to a halt inside the frame was never a dismissal.
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 — past the threshold</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: 5s;
--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);
}
.rig {
width: 200px;
display: grid;
gap: 11px;
animation: rig-cycle var(--t) linear infinite;
}
.cap {
font-size: 11px;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--slate) 52%, transparent);
}
/* Clipped at the list's own edge, so the dismissed card is gone the moment
it leaves the list rather than the moment it leaves the page. */
.stack {
position: relative;
height: 116px;
overflow: hidden;
}
.row {
position: absolute;
left: 0;
width: 100%;
height: 34px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 11px;
border-radius: calc(9px * var(--round));
background: var(--paper);
border: 1.5px solid var(--oat);
}
.r2 {
top: 41px;
}
.r3 {
top: 82px;
}
/* The survivors close the gap together and only once the card is clear of
the band it was in; a list that moves while something is still crossing
it reads as a collision rather than a departure. */
.r2,
.r3 {
animation: close-up var(--t) infinite;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--clay);
flex: none;
}
.bar {
height: 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--slate) 14%, transparent);
}
.card .bar {
width: 96px;
}
.r2 .bar {
width: 78px;
}
.r3 .bar {
width: 106px;
}
.r2,
.r3 {
opacity: 0.72;
}
.card {
top: 0;
z-index: 2;
animation:
swipe var(--t) infinite,
card-fade var(--t) infinite;
}
/* One hand element making both drags, so the 38px it stops at is provably
the same 38px twice — the distance is the control, the time it spends
covering it is the variable. */
.hand {
position: absolute;
left: 6px;
top: 5px;
z-index: 3;
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;
animation:
hand-move var(--t) infinite,
hand-grip var(--t) infinite;
}
.verdict {
display: grid;
font-size: 11px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.verdict span {
grid-area: 1 / 1;
opacity: 0;
}
.v1 {
color: color-mix(in srgb, var(--slate) 55%, transparent);
animation: verdict-held var(--t) infinite;
}
.v2 {
color: var(--clay-d);
animation: verdict-gone var(--t) infinite;
}
/* 325ms to cover 38px, then 50ms to cover the same 38px. The first release
is under the velocity threshold and relaxes home with no overshoot — a
band returns to its own length and does not carry the card past rest.
The second is over it and the card keeps the speed it was let go with,
shedding only a third of it on the way out: a dismissal that decelerates
to a stop inside the frame was never a dismissal. */
@keyframes swipe {
0%,
6% {
transform: translateX(0);
animation-timing-function: linear;
}
12.5% {
transform: translateX(38px);
animation-timing-function: var(--ease-out);
}
19.3% {
transform: translateX(0);
animation-timing-function: linear;
}
32% {
transform: translateX(0);
animation-timing-function: linear;
}
33% {
transform: translateX(38px);
animation-timing-function: linear;
}
36.6% {
transform: translateX(158px);
animation-timing-function: linear;
}
40.6%,
95% {
transform: translateX(246px);
}
95.01%,
100% {
transform: translateX(0);
}
}
@keyframes card-fade {
0%,
36% {
opacity: 1;
animation-timing-function: linear;
}
40.6%,
95% {
opacity: 0;
}
95.01%,
100% {
opacity: 1;
}
}
@keyframes hand-move {
0%,
6% {
transform: translateX(0);
animation-timing-function: linear;
}
12.5%,
20% {
transform: translateX(38px);
}
20.01%,
32% {
transform: translateX(0);
animation-timing-function: linear;
}
33%,
100% {
transform: translateX(38px);
}
}
/* Contact ends on the release frame. Everything after it — the relax home,
the flight out — is the list answering, not the hand still working. */
@keyframes hand-grip {
0%,
4% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
6%,
12.5% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
15%,
30% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
32%,
33% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
35.4%,
100% {
opacity: 0;
}
}
@keyframes close-up {
0%,
38% {
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
43.2%,
95% {
transform: translateY(-41px);
}
95.01%,
100% {
transform: translateY(0);
}
}
@keyframes verdict-held {
0%,
12.5% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
14.9%,
26% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
27.5%,
100% {
opacity: 0;
}
}
@keyframes verdict-gone {
0%,
33% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
35.4%,
100% {
opacity: 1;
}
}
/* The card and the closed-up stack both re-arm at 95%, inside the dark
window, so nothing is ever seen flying back in from the right. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
88% {
opacity: 1;
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* No travel and no dismissal — the verdict alternates instead, which is
the only part of the piece that was ever the message. */
.rig {
animation: none;
opacity: 1;
}
.card {
animation: none;
transform: none;
opacity: 1;
}
.r2,
.r3 {
animation: none;
transform: none;
}
.hand {
animation: none;
opacity: 0;
}
.v1 {
animation: rest-held calc(var(--t) * 1.2) ease infinite;
}
.v2 {
animation: rest-gone calc(var(--t) * 1.2) ease infinite;
}
@keyframes rest-held {
0%,
40% {
opacity: 1;
}
48%,
92% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes rest-gone {
0%,
40% {
opacity: 0;
}
48%,
92% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A notification dragged 38 pixels and let go slowly springs back into the list; dragged the same 38 pixels and flicked, it carries its release speed off the edge and the rows below close up"
>
<p class="cap">same 38px · two release speeds</p>
<div class="stack">
<div class="row r3"><span class="bar"></span></div>
<div class="row r2"><span class="bar"></span></div>
<div class="row card"><span class="dot"></span><span class="bar"></span></div>
<span class="hand"></span>
</div>
<p class="verdict">
<span class="v1">0.12 px/ms · held</span>
<span class="v2">0.76 px/ms · dismissed</span>
</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.