Back to the library

Layout — make room

Variant: Make room

Insertion in the honest order: the grid parts first, the empty slot holds for 130ms, and only then does the newcomer spend its 286ms arriving — scale 0.94 and a fade, decelerating, because an entrance is a response and nothing appears from nothing. Reverse the sequence and the card lands on neighbours still in motion, which is a collision however brief. The same leading gap a drag-reorder opens 32ms early, taken to its conclusion: the room exists before the thing that needs it. Use it when items arrive into a visible collection — a card added to a board, a result inserted into place — and the user should see where it went before it gets there.

layouttransitionlistentrance

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 — make room</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 grid parts over 364ms, holds the empty slot for
           130ms, and only then does the newcomer spend its 286ms arriving. */
        --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);
      }

      /* Five incumbents packed into the first five slots; the newcomer is
         parked in the slot the grid is about to open for it. */
      .c1 { left: 0; top: 0; }
      .c3 { left: 72px; top: 0; }
      .c4 { left: 144px; top: 0; }
      .c5 { left: 0; top: 48px; }
      .c6 { left: 72px; top: 48px; }
      .new { left: 72px; top: 0; }

      .dot {
        width: 7px;
        height: 7px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--slate) 26%, transparent);
      }

      .new .dot {
        background: var(--clay);
      }

      .bar {
        height: 5px;
        border-radius: 2.5px;
        background: color-mix(in srgb, var(--slate) 15%, transparent);
      }

      .c1 .bar { width: 38px; }
      .c3 .bar { width: 44px; }
      .c4 .bar { width: 30px; }
      .c5 .bar { width: 42px; }
      .c6 .bar { width: 34px; }
      .new .bar { width: 26px; }

      .new {
        opacity: 0;
        transform: scale(0.94);
        animation: arrive var(--t) infinite;
      }

      .c3 {
        animation: shift-right var(--t) infinite;
      }

      /* The wrap traveller drops down a row along one straight diagonal, and
         rides above the grid because its path is the only one that crosses
         occupied ground. */
      .c4 {
        z-index: 2;
        animation: wrap-down var(--t) infinite;
      }

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

      /* The room is fully open before the newcomer begins to exist. Reverse the
         order and the card lands on top of a neighbour still in motion — an
         arrival into occupied space is a collision, however brief. The same
         idea as a gap that leads a drag, taken to its conclusion. */
      @keyframes shift-right {
        0%,
        12% {
          transform: translate(0, 0);
          animation-timing-function: var(--ease-in-out);
        }
        19%,
        100% {
          transform: translate(var(--_sw), 0);
        }
      }

      @keyframes wrap-down {
        0%,
        12% {
          transform: translate(0, 0);
          animation-timing-function: var(--ease-in-out);
        }
        19%,
        100% {
          transform: translate(calc(-2 * var(--_sw)), var(--_sh));
        }
      }

      /* Scale 0.94 to 1 with the fade — nothing appears from nothing, and an
         entrance decelerates because it is a response arriving, not a launch. */
      @keyframes arrive {
        0%,
        21.5% {
          opacity: 0;
          transform: scale(0.94);
          animation-timing-function: var(--ease-out);
        }
        27%,
        100% {
          opacity: 1;
          transform: scale(1);
        }
      }

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

      @media (prefers-reduced-motion: reduce) {
        /* The parting keeps its distance and gives up its glide: steps(1)
           opens the slot on one frame. The newcomer keeps only its fade —
           the opacity is the announcement; the scale was just manners. */
        .c3,
        .c5,
        .c6 {
          animation-name: shift-right-step;
        }

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

        .new {
          transform: none;
          animation-name: arrive-still;
        }

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

        @keyframes arrive-still {
          0%,
          21.5% {
            opacity: 0;
            animation-timing-function: ease;
          }
          27%,
          100% {
            opacity: 1;
          }
        }

        @keyframes shift-right-step {
          0%,
          12% {
            transform: translate(0, 0);
            animation-timing-function: steps(1, end);
          }
          19%,
          100% {
            transform: translate(var(--_sw), 0);
          }
        }

        @keyframes wrap-down-step {
          0%,
          12% {
            transform: translate(0, 0);
            animation-timing-function: steps(1, end);
          }
          19%,
          100% {
            transform: translate(calc(-2 * var(--_sw)), var(--_sh));
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="grid"
      role="img"
      aria-label="A grid parts to open an empty slot, holds it for a beat, and a new card fades and scales into the room made for it"
    >
      <div class="card c1"><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 class="card new"><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.