Back to the library
Shortlist cull
Variant: Held verdict
The cull fires at the same speed as the single beat but the narrowed state is held far longer before the set resets, and the return is stretched to nearly twice the outbound pass. Use it where the result is meant to be read, not just registered — a curated recommendation panel or a filter summary that sits on screen. The long hold is the whole point: the fast in, slow out asymmetry makes the verdict feel deliberate rather than momentary.
filterselectionstate-changeholdcards
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>Shortlist cull — held verdict</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
(#d97757 / #b85c3e / #788c5d / #b04a3f). */
--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: 6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--_beat: calc(0.22s / var(--speed));
--_step: 0s;
}
* { box-sizing: border-box; }
html, body { margin: 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);
}
.stage {
width: min(420px, 92vw);
padding: clamp(14px, 4vw, 22px);
background: var(--paper);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: clamp(10px, 3vw, 14px);
}
.head b {
font-size: clamp(12px, 3.2vw, 13px);
font-weight: 600;
letter-spacing: 0.01em;
}
.head span {
font-size: clamp(11px, 2.9vw, 12px);
color: color-mix(in oklab, var(--slate) 48%, var(--paper));
font-variant-numeric: tabular-nums;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(8px, 2.4vw, 11px);
}
.card {
position: relative;
aspect-ratio: 1 / 1.08;
border: 1px solid var(--oat);
border-radius: calc(9px * var(--round));
background: var(--ivory);
overflow: hidden;
transform-origin: 50% 50%;
animation: cull var(--t) var(--ease-out) infinite;
animation-delay: calc(var(--_step) * var(--_i));
}
.card::before {
content: "";
position: absolute;
inset: 26% 30% 30%;
border-radius: calc(3px * var(--round));
background: color-mix(in oklab, var(--slate) 22%, var(--ivory));
}
.card::after {
content: "";
position: absolute;
left: 22%;
right: 22%;
bottom: 15%;
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--slate) 12%, var(--ivory));
}
.card:nth-child(1) { --_i: 0; }
.card:nth-child(2) { --_i: 1; }
.card:nth-child(3) { --_i: 2; }
.card:nth-child(4) { --_i: 3; }
.card:nth-child(5) { --_i: 4; }
.card:nth-child(6) { --_i: 5; }
.card.keep {
animation-name: keep;
animation-delay: 0s;
}
.card.keep::before { background: var(--clay); }
.card.keep::after { background: color-mix(in oklab, var(--clay) 40%, var(--ivory)); }
.ring {
position: absolute;
inset: 0;
border: 1.5px solid var(--clay-d);
border-radius: calc(9px * var(--round));
opacity: 0;
animation: ring var(--t) var(--ease-out) infinite;
}
.foot {
margin-top: clamp(10px, 3vw, 14px);
display: flex;
align-items: center;
gap: 8px;
font-size: clamp(11px, 2.9vw, 12px);
color: color-mix(in oklab, var(--slate) 55%, var(--paper));
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--clay);
opacity: 0;
animation: ring var(--t) var(--ease-out) infinite;
}
.foot em {
font-style: normal;
opacity: 0;
animation: ring var(--t) var(--ease-out) infinite;
animation-delay: calc(0.04s / var(--speed));
}
/* 220ms out (3.67% of 6s), then a hold of ~4.4s, then a 400ms return.
The verdict outlasts the transition that produced it by an order of magnitude. */
@keyframes cull {
0%, 4% { opacity: 1; transform: translateY(0) scale(1); }
7.67% { opacity: 0.26; transform: translateY(2%) scale(0.97); }
90% { opacity: 0.26; transform: translateY(2%) scale(0.97); }
96.67%, 100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes keep {
0%, 4% { transform: scale(1); }
7.67% { transform: scale(1.045); }
90% { transform: scale(1.045); }
96.67%, 100% { transform: scale(1); }
}
@keyframes ring {
0%, 4% { opacity: 0; }
7.67% { opacity: 1; }
90% { opacity: 1; }
95%, 100% { opacity: 0; }
}
@media (max-width: 380px) {
.stage { padding: 13px; }
.grid { gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
/* Already a long loop; 1.5x is enough to soften it without stalling. */
.card,
.card.keep,
.ring,
.dot,
.foot em {
animation-duration: calc(var(--cycle) * 1.5 / var(--speed));
animation-delay: 0s;
}
.card { animation-name: cull-rm; }
.card.keep { animation-name: none; }
@keyframes cull-rm {
0%, 4% { opacity: 1; }
7.67% { opacity: 0.42; }
90% { opacity: 0.42; }
96.67%, 100% { opacity: 1; }
}
}
</style>
</head>
<body>
<div class="stage">
<div class="head">
<b>Shortlist</b>
<span>6 → 1</span>
</div>
<div class="grid">
<div class="card"></div>
<div class="card"></div>
<div class="card keep"><div class="ring"></div></div>
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
</div>
<div class="foot">
<span class="dot"></span>
<em>One selected</em>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.