Back to the library

View change — expand in place

Variant: Expand in place

The one where nothing navigates: the destination was already on screen, so the row discloses and the rows beneath make room. The panel is out of flow and the displacement is a transform of exactly its height plus one gap, which is what keeps this off the layout pass a real height animation would spend on every frame — the price is that the panel has to have a height you know in advance, which a settings disclosure does and arbitrary content does not. The clip edge and the shift share one window and one curve; finish the clip early and the panel is briefly drawn over the row it is still pushing out of the way. The chevron turns 39ms before any of it, because it is the only part of the row the pointer was actually on, and the list rearranging is the consequence rather than the acknowledgement.

transitionlistclip-path

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>View change — expand in place</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.6s. Opening costs 6% of it (336ms) and closing 5% (280ms):
           the disclosure is a decision, the dismissal is housekeeping. */
        --cycle: 5.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-accent: cubic-bezier(0.32, 0.72, 0, 1);

        /* The list's geometry is one set of numbers, because the displacement
           below has to equal the space that opened above it to the pixel — the
           panel is out of flow, so nothing reflows to correct a mismatch. */
        --_row: 21px;
        --_gap: 6px;
        --_panel: 44px;
        --_open: calc(var(--_panel) + var(--_gap));
      }

      * {
        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);
      }

      .list {
        position: relative;
        width: clamp(212px, 72vw, 268px);
        display: grid;
        gap: var(--_gap);
      }

      .row {
        position: relative;
        display: flex;
        align-items: center;
        gap: 8px;
        height: var(--_row);
        padding: 0 9px;
        border-radius: calc(6px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 9%, transparent);
        font-size: 11px;
        letter-spacing: -0.005em;
      }

      /* Rows below the disclosure are pushed by a transform, not by a reflow.
         That is the trade this pattern makes: a real layout animation would let
         the panel be any height, and would cost a layout pass on every frame of
         the open. A fixed displacement costs nothing and demands that the panel
         have a height you know in advance — which a settings disclosure does. */
      .r3,
      .r4 {
        animation: displace var(--t) infinite;
      }

      .r2 {
        animation: selected var(--t) infinite;
      }

      .value {
        margin-left: auto;
        font-size: 10.5px;
        color: color-mix(in srgb, var(--slate) 45%, transparent);
      }

      .chev {
        width: 10px;
        height: 10px;
        flex: none;
        color: color-mix(in srgb, var(--slate) 40%, transparent);
        transform: rotate(0deg);
        animation: chevron var(--t) infinite;
      }

      .chev svg {
        width: 100%;
        height: 100%;
        display: block;
      }

      /* Out of flow, parked in the gap it is about to occupy: two rows and two
         gaps down from the top of the list. */
      .panel {
        position: absolute;
        left: 0;
        right: 0;
        top: calc(2 * var(--_row) + 2 * var(--_gap));
        height: var(--_panel);
        display: flex;
        align-items: center;
        gap: 9px;
        padding: 0 10px;
        border-radius: calc(6px * var(--round));
        background: color-mix(in srgb, var(--oat) 32%, var(--paper));
        clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
        animation: unroll var(--t) infinite;
      }

      .panel .swatch {
        width: 15px;
        height: 15px;
        flex: none;
        border-radius: calc(4px * var(--round));
        background: var(--clay);
      }

      .panel .lines {
        flex: 1;
        display: grid;
        gap: 5px;
      }

      .panel .line {
        height: 6px;
        border-radius: 3px;
        background: color-mix(in srgb, var(--slate) 14%, transparent);
      }

      /* The panel's contents are not what opened; the panel is. They arrive
         after it has stopped, which is also the only way they avoid being
         read through a clip edge travelling over them. */
      .panel .swatch,
      .panel .lines {
        opacity: 0;
        animation: panel-content var(--t) infinite;
      }

      /* One curve, one clock, one window: the clip edge and the displacement are
         the two halves of the same opening. Finish the clip early and the panel
         is briefly drawn over the row it is still pushing out of the way. */
      @keyframes unroll {
        0%,
        10.7% {
          clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
          animation-timing-function: var(--ease-accent);
        }
        16.7%,
        57.8% {
          clip-path: inset(0 0 0 0 round calc(6px * var(--round)));
          animation-timing-function: var(--ease-accent);
        }
        62.8%,
        100% {
          clip-path: inset(0 0 100% 0 round calc(6px * var(--round)));
        }
      }

      @keyframes displace {
        0%,
        10.7% {
          transform: translateY(0);
          animation-timing-function: var(--ease-accent);
        }
        16.7%,
        57.8% {
          transform: translateY(var(--_open));
          animation-timing-function: var(--ease-accent);
        }
        62.8%,
        100% {
          transform: translateY(0);
        }
      }

      /* The chevron turns 39ms before anything moves. It is the only part of the
         row the pointer was actually on, so it is the part that has to answer
         first — the list rearranging is the consequence, not the acknowledgement. */
      @keyframes chevron {
        0%,
        10% {
          transform: rotate(0deg);
          animation-timing-function: var(--ease-accent);
        }
        14%,
        57.8% {
          transform: rotate(90deg);
          animation-timing-function: var(--ease-accent);
        }
        61.4%,
        100% {
          transform: rotate(0deg);
        }
      }

      @keyframes selected {
        0%,
        10.7% {
          background: var(--paper);
          animation-timing-function: ease;
        }
        14%,
        57.8% {
          background: color-mix(in srgb, var(--clay) 9%, var(--paper));
          animation-timing-function: ease;
        }
        61%,
        100% {
          background: var(--paper);
        }
      }

      @keyframes panel-content {
        0%,
        16.7% {
          opacity: 0;
          animation-timing-function: ease;
        }
        20.5%,
        56% {
          opacity: 1;
          animation-timing-function: ease;
        }
        57.8%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The displacement cannot simply be dropped — remove it and the panel
           opens on top of the rows it is meant to move. So it keeps its
           distance and gives up its interpolation: steps(1) puts the rows in
           their new places on the frame the disclosure changes state, which is
           what a list with no animation at all does. The panel then arrives the
           only way left to it, as opacity. */
        .r3,
        .r4 {
          animation-name: displace-step;
        }

        .chev {
          animation-name: chevron-step;
        }

        .panel {
          clip-path: none;
          opacity: 0;
          animation-name: panel-content;
        }

        /* The panel itself now carries the fade, so its contents stop being a
           second beat and simply come with it. */
        .panel .swatch,
        .panel .lines {
          animation: none;
          opacity: 1;
        }

        .r2,
        .r3,
        .r4,
        .chev,
        .panel {
          animation-duration: calc(var(--t) * 1.5);
        }

        @keyframes displace-step {
          0%,
          10.7% {
            transform: translateY(0);
            animation-timing-function: steps(1, end);
          }
          16.7%,
          57.8% {
            transform: translateY(var(--_open));
            animation-timing-function: steps(1, end);
          }
          62.8%,
          100% {
            transform: translateY(0);
          }
        }

        @keyframes chevron-step {
          0%,
          10% {
            transform: rotate(0deg);
            animation-timing-function: steps(1, end);
          }
          14%,
          57.8% {
            transform: rotate(90deg);
            animation-timing-function: steps(1, end);
          }
          61.4%,
          100% {
            transform: rotate(0deg);
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="list"
      role="img"
      aria-label="A settings row disclosing a panel in place: the chevron turns, the rows below move down by exactly the panel's height, and the panel unrolls into the gap"
    >
      <div class="row r1">
        <span>Alerts</span>
        <span class="value">On</span>
      </div>
      <div class="row r2">
        <span class="chev">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path
              d="m9 5 7 7-7 7"
              stroke="currentColor"
              stroke-width="2.6"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </span>
        <span>Appearance</span>
        <span class="value">Clay</span>
      </div>
      <div class="panel">
        <span class="swatch"></span>
        <span class="lines">
          <span class="line"></span>
          <span class="line" style="width: 64%"></span>
        </span>
      </div>
      <div class="row r3">
        <span>Sounds</span>
        <span class="value">Off</span>
      </div>
      <div class="row r4">
        <span>Storage</span>
        <span class="value">2.1 GB</span>
      </div>
    </div>
  </body>
</html>