Back to the library

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.

layouttransitionlistcontrolloop

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.