Back to the library

View change — shared element

Variant: Shared element

Continuity done properly: the tile that was tapped is the card that arrives, so nothing has to be recognised twice. The whole travel is one translate and one scale off a top-left origin, and both numbers fall out of the gutter alone — with a gutter fraction g, one cell plus one gutter is (1+g)/(1−g) of a cell and the whole stage is 2/(1−g), and those differ by exactly one. The corners scale with everything else, so 6px lands as 12.5px; that is the one artifact a uniform-scale shared element cannot dodge, and it is still the better trade than animating width and height into a layout pass on every frame. The caption is not shared and so does not travel: it arrives 56ms after the card stops and leaves 140ms before it starts back, because content riding a shrinking surface reads as falling into it.

transitionmorphentrance

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 — shared element</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: grid, the tile becomes the view, the view holds, it goes
           back to being a tile. The growth is 6% of the cycle (336ms) and the
           return 5.5% (308ms). */
        --cycle: 5.6s;
        --t: calc(var(--cycle) / var(--speed));

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

        /* The gutter, as a fraction of the stage in both axes — which is what
           makes the whole transition one number. A two-column grid with a
           gutter g means one cell plus one gutter is (1+g)/(1-g) of a cell, and
           the whole stage is 2/(1-g) of a cell. Those are the shift and the
           scale, and they differ by exactly 1. */
        --_gut: 0.04;
        --_grow: calc(2 / (1 - var(--_gut)));
      }

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

      .frame {
        position: relative;
        width: clamp(228px, 74vw, 292px);
        aspect-ratio: 3 / 2;
        border-radius: calc(12px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
        overflow: hidden;
      }

      /* A definite box in both axes, so the percentage gutter resolves against
         a known width and a known height and the cells stay square-ish at every
         stage size the card is previewed at. */
      .stage {
        position: absolute;
        inset: 12px;
        display: grid;
        grid-template-columns: 1fr 1fr;
        grid-template-rows: 1fr 1fr;
        gap: calc(var(--_gut) * 100%);
      }

      .tile {
        border-radius: calc(6px * var(--round));
        background: color-mix(in srgb, var(--slate) 7%, transparent);
      }

      /* The siblings are gone before the card is over them. Overlap the two and
         the card reads as a new surface landing on top of the grid, rather than
         as the grid becoming the card. */
      .tile:not(.hero) {
        animation: siblings var(--t) infinite;
      }

      .tile:nth-child(3) {
        background: color-mix(in srgb, var(--olive) 20%, transparent);
      }

      /* Origin at the tile's own top-left, so the two functions read straight
         off the geometry: shift one cell-plus-gutter to the left, then scale the
         cell up to the stage. No measuring, no per-size maths, no translate in
         y — the tile is already on the row it is going to fill. */
      .hero {
        position: relative;
        z-index: 1;
        background: var(--clay);
        transform-origin: 0 0;
        animation: expand var(--t) infinite;
      }

      /* Scaling a rounded box scales its corners too — 6px arrives as 12.5px.
         That is the one artifact a uniform-scale shared element cannot dodge,
         and it is still the right trade: 12.5px is a plausible radius for a
         card this size, whereas animating width and height to avoid it costs a
         layout pass on every frame of the travel. */
      .hero .mark {
        position: absolute;
        top: 22%;
        left: 12%;
        width: 26%;
        aspect-ratio: 1;
        border-radius: 50%;
        background: color-mix(in srgb, var(--paper) 42%, transparent);
      }

      /* Only the element is shared. The destination's own content is not in the
         grid anywhere, so it cannot travel from it — it arrives afterwards, as
         its own step, once the card has stopped. */
      .caption {
        position: absolute;
        left: 8px;
        right: 8px;
        bottom: 8px;
        z-index: 2;
        padding: 9px 10px;
        display: grid;
        gap: 6px;
        border-radius: calc(7px * var(--round));
        background: color-mix(in srgb, var(--paper) 93%, transparent);
        opacity: 0;
        transform: translateY(5px);
        animation: caption var(--t) infinite;
      }

      .caption .title {
        font-size: 11.5px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .caption .line {
        height: 6px;
        border-radius: 3px;
        background: var(--oat);
      }

      @keyframes expand {
        0%,
        10.5% {
          transform: translateX(0) scale(1);
          animation-timing-function: var(--ease-accent);
        }
        16.5%,
        60.5% {
          transform: translateX(calc((1 - var(--_grow)) * 100%)) scale(var(--_grow));
          animation-timing-function: var(--ease-accent);
        }
        66%,
        100% {
          transform: translateX(0) scale(1);
        }
      }

      @keyframes siblings {
        0%,
        9% {
          opacity: 1;
          animation-timing-function: ease;
        }
        12.5%,
        64.5% {
          opacity: 0;
          animation-timing-function: ease;
        }
        68.5%,
        100% {
          opacity: 1;
        }
      }

      /* In at 17.5%, a beat after the card settles at 16.5%; out at 58%, a beat
         before it starts back. Content that leaves with the card it is sitting
         on rides a shrinking surface and reads as falling into it. */
      @keyframes caption {
        0%,
        17.5% {
          opacity: 0;
          transform: translateY(5px);
          animation-timing-function: var(--ease-accent);
        }
        22%,
        58% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: ease;
        }
        60.5%,
        100% {
          opacity: 0;
          transform: translateY(5px);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* A shared element is travel by definition — with the travel gone there
           is no transition left to soften, only a destination. So the card is
           parked at it and the one thing that was never part of the travel keeps
           running: the view's own content arriving after the surface it sits on. */
        .hero {
          animation: none;
          transform: translateX(calc((1 - var(--_grow)) * 100%)) scale(var(--_grow));
        }

        .tile:not(.hero) {
          animation: none;
          opacity: 0;
        }

        .caption {
          transform: none;
          animation: caption-fade calc(var(--t) * 1.5) ease infinite;
        }

        @keyframes caption-fade {
          0%,
          17.5% {
            opacity: 0;
          }
          22%,
          58% {
            opacity: 1;
          }
          62%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="frame"
      role="img"
      aria-label="A tile in a grid growing into a full card view, its caption arriving once it settles, then shrinking back to its place in the grid"
    >
      <div class="stage">
        <div class="tile"></div>
        <div class="tile hero"><span class="mark"></span></div>
        <div class="tile"></div>
        <div class="tile"></div>
        <div class="caption">
          <span class="title">Harbour, 06:40</span>
          <span class="line" style="width: 88%"></span>
          <span class="line" style="width: 62%"></span>
        </div>
      </div>
    </div>
  </body>
</html>