Back to the library

Option set narrowing with a left-to-right sweep

Variant: Sweep stagger

The same chip rack collapsing to one recommendation, but the rejects drop out in sequence at 55ms apart so the eye can follow the pass rather than being handed a result. Reach for this when the narrowing itself is the message — a filter being applied, a "we removed the options nobody picks" explainer. The kept chip is excluded from the stagger and lifts on the shared beat, so it reads as the survivor of the sweep rather than another step in it.

filterstaggerchipsstate-changeloop

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>Option set narrowing — sweep stagger</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: 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);

  /* 55ms between rejects: inside the 30-80ms cascade window, and 7 steps
     still land the last chip before the settle. */
  --_step: calc(55ms / var(--speed));
  --_dim: #8c857a;
}

* { 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);
  padding: 16px;
}

.card {
  width: min(400px, 92vw);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
  padding: clamp(14px, 4.5vw, 20px);
  display: grid;
  gap: clamp(12px, 3.5vw, 16px);
}

.head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}

.title {
  font-size: clamp(12px, 3.4vw, 13.5px);
  font-weight: 600;
  letter-spacing: 0.01em;
}

.count {
  font-size: clamp(11px, 3vw, 12px);
  color: var(--_dim);
  font-variant-numeric: tabular-nums;
  display: inline-grid;
}

.count span {
  grid-area: 1 / 1;
  text-align: right;
}

.count .many { animation: countMany var(--t) var(--ease-out) infinite; }
.count .few  {
  animation: countFew var(--t) var(--ease-out) infinite;
  /* Label flips only after the last chip has fallen. */
  animation-delay: calc(var(--_step) * 7);
  color: var(--clay-d);
  font-weight: 600;
}

@keyframes countMany {
  0%, 14%   { opacity: 1; }
  26%, 82%  { opacity: 0; }
  96%, 100% { opacity: 1; }
}
@keyframes countFew {
  0%, 16%   { opacity: 0; }
  30%, 80%  { opacity: 1; }
  94%, 100% { opacity: 0; }
}

.rack {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(6px, 2vw, 8px);
}

.chip {
  --_i: 0;
  flex: 0 0 auto;
  min-width: clamp(46px, 14vw, 58px);
  height: clamp(28px, 8vw, 32px);
  border-radius: 999px;
  border: 1px solid var(--oat);
  background: var(--ivory);
  display: grid;
  place-items: center;
  font-size: clamp(11px, 3vw, 12px);
  color: var(--_dim);
  transform-origin: 50% 50%;
  will-change: transform, opacity;
  animation: reject var(--t) var(--ease-in-out) infinite;
  animation-delay: calc(var(--_i) * var(--_step));
}

.chip.keep {
  border-color: var(--clay);
  color: var(--paper);
  background: var(--clay);
  font-weight: 600;
  animation-name: keep;
  animation-delay: 0s; /* the survivor sits outside the cascade */
}

@keyframes reject {
  0%, 12%   { transform: scale(1); opacity: 1; }
  30%, 78%  { transform: scale(0.94); opacity: 0.18; }
  96%, 100% { transform: scale(1); opacity: 1; }
}

@keyframes keep {
  0%, 12%   { transform: scale(1) translateY(0); opacity: 1; }
  30%, 78%  { transform: scale(1.04) translateY(-1px); opacity: 1; }
  96%, 100% { transform: scale(1) translateY(0); opacity: 1; }
}

.rail {
  height: 3px;
  border-radius: 999px;
  background: var(--oat);
  overflow: hidden;
}

.rail i {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--olive);
  transform-origin: 0 50%;
  transform: scaleX(0.24);
  animation: rail var(--t) var(--ease-out) infinite;
  animation-delay: calc(var(--_step) * 3);
}

@keyframes rail {
  0%, 12%   { transform: scaleX(1); }
  30%, 78%  { transform: scaleX(0.24); }
  96%, 100% { transform: scaleX(1); }
}

.foot {
  font-size: clamp(10.5px, 2.9vw, 11.5px);
  color: var(--_dim);
  display: flex;
  align-items: center;
  gap: 6px;
}

.dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--rust);
  animation: dot var(--t) var(--ease-out) infinite;
  animation-delay: calc(var(--_step) * 7);
}

@keyframes dot {
  0%, 12%   { opacity: 0.35; }
  30%, 78%  { opacity: 1; }
  96%, 100% { opacity: 0.35; }
}

@media (max-width: 380px) {
  .card { padding: 12px; gap: 11px; }
  .rack { gap: 6px; }
}

@media (prefers-reduced-motion: reduce) {
  /* The cascade collapses to one shared fade; the loop keeps running at half pace. */
  .chip,
  .chip.keep {
    animation-duration: calc(var(--t) * 2);
    animation-delay: 0s;
  }
  @keyframes reject {
    0%, 12%   { transform: none; opacity: 1; }
    30%, 78%  { transform: none; opacity: 0.3; }
    96%, 100% { transform: none; opacity: 1; }
  }
  @keyframes keep {
    0%, 100%  { transform: none; opacity: 1; }
  }
  .rail i,
  .count .many,
  .count .few,
  .dot {
    animation-duration: calc(var(--t) * 2);
    animation-delay: 0s;
  }
}
</style>
</head>
<body>
  <div class="card">
    <div class="head">
      <div class="title">Plan</div>
      <div class="count">
        <span class="many">24 shown</span>
        <span class="few">1 suggested</span>
      </div>
    </div>

    <div class="rack">
      <div class="chip" style="--_i:0">S</div>
      <div class="chip" style="--_i:1">M</div>
      <div class="chip keep" style="--_i:0">Std</div>
      <div class="chip" style="--_i:2">L</div>
      <div class="chip" style="--_i:3">XL</div>
      <div class="chip" style="--_i:4">Pro</div>
      <div class="chip" style="--_i:5">Max</div>
      <div class="chip" style="--_i:6">Team</div>
    </div>

    <div class="rail"><i></i></div>
    <div class="foot"><span class="dot"></span>Filtered to the best fit</div>
  </div>
</body>
</html>