Back to the library

Layout — close ranks

Variant: Close ranks

A card leaves a grid and the survivors reflow — but not at once. The exit takes its 260ms, the hole holds for a 208ms beat, and only then does the grid answer, because a reflow that starts while the card is still fading reads as shoving it out rather than reacting to its loss. Every traveller shares one 364ms window on one curve, so the card wrapping up a row simply moves faster along its diagonal: distance sets speed, never the other way round. Reach for it on dismissals, deletions, and archive actions in any card grid — the pattern FLIP libraries automate, laid out here so the choreography is legible.

layouttransitionlist

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 — close ranks</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 — 5.2s. The exit costs 260ms, the reflow 364ms, and between them
           sits a 208ms beat where the grid holds a hole. */
        --cycle: 5.2s;
        --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);
      }

      .grid {
        position: relative;
        width: 208px;
        height: 88px;
        animation: scene var(--t) linear infinite;
      }

      .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) 26%, transparent);
      }

      /* The card being dismissed is the one the pointer was on — clay marks the
         subject of the interaction, not a category. */
      .c2 .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 {
        animation: leave var(--t) infinite;
      }

      .c3,
      .c5,
      .c6 {
        animation: shift-left var(--t) infinite;
      }

      /* The wrap traveller crosses two columns and a row on one straight
         diagonal, and rides above the grid while it does: it is the only card
         whose path crosses another card's slot. */
      .c4 {
        z-index: 2;
        animation: wrap-up var(--t) infinite;
      }

      /* Scale 0.9, never 0 — the card recedes, it does not vanish into a point. */
      @keyframes leave {
        0%,
        12% {
          transform: scale(1);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        17%,
        100% {
          transform: scale(0.9);
          opacity: 0;
        }
      }

      /* The hole holds for 208ms after the exit finishes before anyone moves.
         Trailing, not leading: the removal is acknowledged first, and only then
         do its consequences arrive — a grid that reflows while the card is
         still fading reads as shoving it out rather than reacting to its loss. */
      @keyframes shift-left {
        0%,
        21% {
          transform: translate(0, 0);
          animation-timing-function: var(--ease-in-out);
        }
        28%,
        100% {
          transform: translate(calc(-1 * var(--_sw)), 0);
        }
      }

      /* Same 364ms window as every other traveller: in a reflow the shared
         duration is the contract, so the diagonal card simply moves faster.
         Distance sets speed, never the other way round. */
      @keyframes wrap-up {
        0%,
        21% {
          transform: translate(0, 0);
          animation-timing-function: var(--ease-in-out);
        }
        28%,
        100% {
          transform: translate(calc(2 * var(--_sw)), calc(-1 * var(--_sh)));
        }
      }

      @keyframes scene {
        0% {
          opacity: 0;
        }
        3.5%,
        80% {
          opacity: 1;
        }
        87%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Travels give up their interpolation, not their distance: steps(1)
           puts every survivor in its new slot on one frame, which is what an
           unanimated grid does. The exit keeps its fade — opacity is the part
           carrying the meaning — and drops the recede. */
        .c3,
        .c5,
        .c6 {
          animation-name: shift-left-step;
        }

        .c4 {
          animation-name: wrap-up-step;
        }

        .c2 {
          animation-name: leave-still;
        }

        .grid,
        .c2,
        .c3,
        .c4,
        .c5,
        .c6 {
          animation-duration: calc(var(--t) * 1.5);
        }

        @keyframes leave-still {
          0%,
          12% {
            opacity: 1;
            animation-timing-function: ease;
          }
          17%,
          100% {
            opacity: 0;
          }
        }

        @keyframes shift-left-step {
          0%,
          21% {
            transform: translate(0, 0);
            animation-timing-function: steps(1, end);
          }
          28%,
          100% {
            transform: translate(calc(-1 * var(--_sw)), 0);
          }
        }

        @keyframes wrap-up-step {
          0%,
          21% {
            transform: translate(0, 0);
            animation-timing-function: steps(1, end);
          }
          28%,
          100% {
            transform: translate(calc(2 * var(--_sw)), calc(-1 * var(--_sh)));
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="grid"
      role="img"
      aria-label="A card is removed from a grid; after a beat the remaining cards travel to fill the gap, one wrapping up a row"
    >
      <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>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.