Layout — filter and settle
Variant: Filter and settle
A filter chip activates and the grid answers in three separated beats: the chip changes first — it is the thing that was pressed — then the non-matches fade out in place with no direction, because excluded is not sent anywhere, and only after a 160ms beat do the survivors compact into the front slots. The same discipline runs backwards when the filter clears: travel first, re-entries after, so a card only ever fades into a slot that is already empty. Both directions are real interactions, which is why nothing dissolves to reset. Reach for it on tag filters, search-as-you-type over cards, and any faceted browse where users lose track of what went away.
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>Layout — filter and settle</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 — 6.4s: filter on, exits, a beat, compaction, a long hold to
read the filtered state, then the whole sequence unwinds in mirror
order. Both directions are real interactions, so nothing dissolves. */
--cycle: 6.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);
/* Slot pitch — card plus gap, so every travel lands in a slot. */
--_sw: 72px;
--_sh: 48px;
}
* {
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);
}
.board {
display: grid;
gap: 12px;
justify-items: start;
}
.chip {
position: relative;
display: flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 10px;
border-radius: 11px;
font-size: 11px;
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--slate) 72%, transparent);
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
animation: chip-state var(--t) infinite;
}
.chip .tick {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--clay);
}
.tap {
position: absolute;
left: 24px;
top: 50%;
width: 22px;
height: 22px;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 11%, transparent);
translate: 0 -50%;
opacity: 0;
animation: tap var(--t) infinite;
}
.grid {
position: relative;
width: 208px;
height: 88px;
}
.card {
position: absolute;
width: 64px;
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 0 9px;
border-radius: calc(7px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
}
.c1 { left: 0; top: 0; }
.c2 { left: 72px; top: 0; }
.c3 { left: 144px; top: 0; }
.c4 { left: 0; top: 48px; }
.c5 { left: 72px; top: 48px; }
.c6 { left: 144px; top: 48px; }
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 22%, transparent);
}
/* Clay dots are the matching tag — the chip and the survivors share the
one accent, so cause and effect are wearing the same colour. */
.c1 .dot,
.c3 .dot,
.c5 .dot {
background: var(--clay);
}
.bar {
height: 5px;
border-radius: 2.5px;
background: color-mix(in srgb, var(--slate) 15%, transparent);
}
.c1 .bar { width: 38px; }
.c2 .bar { width: 26px; }
.c3 .bar { width: 44px; }
.c4 .bar { width: 30px; }
.c5 .bar { width: 42px; }
.c6 .bar { width: 34px; }
.c2,
.c4,
.c6 {
animation: ghost var(--t) infinite;
}
.c3 {
animation: settle-left var(--t) infinite;
}
/* The one survivor that changes rows crosses the grid diagonally, above
it — its path is the only one through occupied ground. */
.c5 {
z-index: 2;
animation: settle-wrap var(--t) infinite;
}
/* The full causal chain, in order: the finger lands, the chip answers,
the grid follows. Each tap makes contact a beat before the chip's
state change, because the press is the cause and the colour is the
acknowledgement — collapse them onto one frame and the chip looks
like it changed on its own. */
@keyframes tap {
0%,
2.5% {
opacity: 0;
transform: scale(1.35);
animation-timing-function: var(--ease-out);
}
5%,
8% {
opacity: 1;
transform: scale(1);
}
11.5%,
48% {
opacity: 0;
transform: scale(1.35);
animation-timing-function: var(--ease-out);
}
50.5%,
53.5% {
opacity: 1;
transform: scale(1);
}
57%,
100% {
opacity: 0;
transform: scale(1);
}
}
/* The chip answers first: it is the thing that was pressed, so its state
change leads the exits rather than arriving with them. */
@keyframes chip-state {
0%,
6% {
background: var(--paper);
border-color: color-mix(in srgb, var(--slate) 12%, transparent);
animation-timing-function: ease;
}
9.5%,
52% {
background: color-mix(in srgb, var(--clay) 12%, var(--paper));
border-color: color-mix(in srgb, var(--clay) 55%, transparent);
animation-timing-function: ease;
}
55.5%,
100% {
background: var(--paper);
border-color: color-mix(in srgb, var(--slate) 12%, transparent);
}
}
/* Non-matches leave in place — they are being excluded, not sent
anywhere, so giving them a direction would claim a destination. On the
way back they return only after the survivors have re-opened their
slots: the grid makes the room before a card refills it, in both
directions. */
@keyframes ghost {
0%,
9.5% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
13.5%,
62.5% {
opacity: 0;
transform: scale(0.94);
animation-timing-function: var(--ease-out);
}
67%,
100% {
opacity: 1;
transform: scale(1);
}
}
/* Survivors move only once the space is actually empty — a 160ms beat
after the last exit. Compacting through cards that are still fading
reads as the grid crushing them. */
@keyframes settle-left {
0%,
16% {
transform: translate(0, 0);
animation-timing-function: var(--ease-in-out);
}
22%,
55.5% {
transform: translate(calc(-1 * var(--_sw)), 0);
animation-timing-function: var(--ease-in-out);
}
61.5%,
100% {
transform: translate(0, 0);
}
}
@keyframes settle-wrap {
0%,
16% {
transform: translate(0, 0);
animation-timing-function: var(--ease-in-out);
}
22%,
55.5% {
transform: translate(var(--_sw), calc(-1 * var(--_sh)));
animation-timing-function: var(--ease-in-out);
}
61.5%,
100% {
transform: translate(0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* Travels snap between slots on steps(1); the ghosts keep their fades
with the scale removed, and the chip keeps its colour — state is the
content here, motion was only the explanation. */
.c3 {
animation-name: settle-left-step;
}
.c5 {
animation-name: settle-wrap-step;
}
.c2,
.c4,
.c6 {
animation-name: ghost-still;
}
/* The tap keeps only its fade — important beats the keyframes' scale. */
.tap {
transform: none !important;
}
.chip,
.tap,
.c2,
.c3,
.c4,
.c5,
.c6 {
animation-duration: calc(var(--t) * 1.5);
}
@keyframes ghost-still {
0%,
9.5% {
opacity: 1;
animation-timing-function: ease;
}
13.5%,
62.5% {
opacity: 0;
animation-timing-function: ease;
}
67%,
100% {
opacity: 1;
}
}
@keyframes settle-left-step {
0%,
16% {
transform: translate(0, 0);
animation-timing-function: steps(1, end);
}
22%,
55.5% {
transform: translate(calc(-1 * var(--_sw)), 0);
animation-timing-function: steps(1, end);
}
61.5%,
100% {
transform: translate(0, 0);
}
}
@keyframes settle-wrap-step {
0%,
16% {
transform: translate(0, 0);
animation-timing-function: steps(1, end);
}
22%,
55.5% {
transform: translate(var(--_sw), calc(-1 * var(--_sh)));
animation-timing-function: steps(1, end);
}
61.5%,
100% {
transform: translate(0, 0);
}
}
}
</style>
</head>
<body>
<div
class="board"
role="img"
aria-label="A pointer taps a filter chip: unmatched cards fade out in place, the tagged survivors compact to the front of the grid, then a second tap clears the filter and everything returns"
>
<div class="chip"><span class="tick"></span><span>Clay</span><span class="tap"></span></div>
<div class="grid">
<div class="card c1"><span class="dot"></span><span class="bar"></span></div>
<div class="card c2"><span class="dot"></span><span class="bar"></span></div>
<div class="card c3"><span class="dot"></span><span class="bar"></span></div>
<div class="card c4"><span class="dot"></span><span class="bar"></span></div>
<div class="card c5"><span class="dot"></span><span class="bar"></span></div>
<div class="card c6"><span class="dot"></span><span class="bar"></span></div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.