Back to the library

View change — direction-aware push

Variant: Direction-aware push

The default answer for a hierarchy you can go down into and come back out of. The incoming view crosses the full frame; the outgoing one covers 30% of that and takes a scrim while it goes, so for the whole crossing the previous view is visibly still there, behind — which is what makes "back" a place rather than a command. Forward and back both cost 291ms on purpose: a stack that pops faster than it pushes has stopped being reversible, and reversibility is the entire claim a direction-aware transition makes. The chrome does not travel with either view; the title crossfades with 6px of the same direction, enough to belong to the gesture without being a second animation running beside it, and the two labels are sequenced rather than symmetric so they never sit at half opacity on the same frame.

transitioncontrolentrance

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 — direction-aware push</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: rest, push forward, sit on the detail, pop back, rest.
           Both crossings are 5.6% of the cycle, which is 291ms. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        /* The navigation curve: fast off the mark, long settle. */
        --ease-accent: cubic-bezier(0.32, 0.72, 0, 1);

        /* The parallax ratio. The outgoing view covers 30% of what the incoming
           one covers — enough that it is visibly still there, behind, waiting to
           be come back to, rather than a page that left. */
        --_behind: -30%;

        /* The two sub-pixel-budget offsets in the chrome, held as tokens so the
           reduced-motion block can zero both of them in one declaration. */
        --_nudge: 6px;
        --_lead: 4px;
      }

      * {
        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;
        display: grid;
        grid-template-rows: auto 1fr;
        border-radius: calc(12px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
        overflow: hidden;
      }

      /* The chrome does not travel. It reports where you are; it is not one of
         the places you went. */
      .chrome {
        position: relative;
        height: 34px;
        display: flex;
        align-items: center;
        padding: 0 12px;
        gap: 8px;
        border-bottom: 1px solid color-mix(in srgb, var(--slate) 8%, transparent);
        background: var(--ivory);
      }

      .back {
        display: grid;
        place-items: center;
        width: 14px;
        height: 14px;
        flex: none;
        color: var(--clay-d);
        opacity: 0;
        transform: translateX(calc(-1 * var(--_lead)));
        animation: back-affordance var(--t) infinite;
      }

      .back svg {
        width: 100%;
        height: 100%;
      }

      .titles {
        position: relative;
        flex: 1;
        height: 14px;
      }

      /* 6px of the same direction — enough for the label to belong to the
         gesture, not enough to be a second animation running beside it. */
      .title {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .title-list {
        animation: title-out var(--t) infinite;
      }

      .title-detail {
        opacity: 0;
        transform: translateX(var(--_nudge));
        animation: title-in var(--t) infinite;
      }

      .stage {
        position: relative;
        overflow: hidden;
      }

      .pane {
        position: absolute;
        inset: 0;
        padding: 12px;
        display: grid;
        gap: 9px;
        align-content: start;
        background: var(--paper);
      }

      .list {
        animation: push-behind var(--t) infinite;
      }

      .detail {
        transform: translateX(100%);
        border-left: 1px solid color-mix(in srgb, var(--slate) 9%, transparent);
        animation: push-in var(--t) infinite;
      }

      .row {
        height: 16px;
        border-radius: calc(5px * var(--round));
        background: color-mix(in srgb, var(--slate) 8%, transparent);
      }

      .row-live {
        background: color-mix(in srgb, var(--clay) 26%, transparent);
      }

      .detail .lead {
        height: 11px;
        width: 62%;
        border-radius: calc(4px * var(--round));
        background: var(--clay);
      }

      .detail .line {
        height: 8px;
        border-radius: 4px;
        background: var(--oat);
      }

      .detail .chip {
        height: 20px;
        width: 46%;
        margin-top: 2px;
        border-radius: calc(6px * var(--round));
        background: color-mix(in srgb, var(--olive) 24%, transparent);
      }

      /* Dim the layer, not the content: a scrim over the outgoing view keeps its
         text at full contrast underneath, so nothing has to be re-rendered at a
         weight it never has at rest. */
      .dim {
        position: absolute;
        inset: 0;
        background: color-mix(in srgb, var(--slate) 14%, transparent);
        opacity: 0;
        animation: dim var(--t) infinite;
      }

      /* Forward and back cost exactly the same. A stack that pops faster than it
         pushes stops being reversible, and reversibility is the only thing a
         direction-aware transition is actually claiming. */
      @keyframes push-in {
        0%,
        8% {
          transform: translateX(100%);
          animation-timing-function: var(--ease-accent);
        }
        13.6%,
        54% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        59.6%,
        100% {
          transform: translateX(100%);
        }
      }

      @keyframes push-behind {
        0%,
        8% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        13.6%,
        54% {
          transform: translateX(var(--_behind));
          animation-timing-function: var(--ease-accent);
        }
        59.6%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes dim {
        0%,
        8% {
          opacity: 0;
          animation-timing-function: ease;
        }
        13.6%,
        54% {
          opacity: 1;
          animation-timing-function: ease;
        }
        59.6%,
        100% {
          opacity: 0;
        }
      }

      @keyframes back-affordance {
        0%,
        9% {
          opacity: 0;
          transform: translateX(calc(-1 * var(--_lead)));
          animation-timing-function: var(--ease-accent);
        }
        13.6%,
        53% {
          opacity: 1;
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        57%,
        100% {
          opacity: 0;
          transform: translateX(calc(-1 * var(--_lead)));
        }
      }

      /* The two labels are sequenced rather than symmetric: the outgoing one is
         gone in 187ms and the incoming one starts 52ms before it finishes.
         Fade them on identical windows and both sit near half opacity on the
         same frame, which on two words in the same box reads as one illegible
         word rather than as a change of place. */
      @keyframes title-out {
        0%,
        8% {
          opacity: 1;
          transform: translateX(0);
          animation-timing-function: ease;
        }
        11.6%,
        56.6% {
          opacity: 0;
          transform: translateX(calc(-1 * var(--_nudge)));
          animation-timing-function: ease;
        }
        59.6%,
        100% {
          opacity: 1;
          transform: translateX(0);
        }
      }

      @keyframes title-in {
        0%,
        10.6% {
          opacity: 0;
          transform: translateX(var(--_nudge));
          animation-timing-function: ease;
        }
        13.6%,
        54% {
          opacity: 1;
          transform: translateX(0);
          animation-timing-function: ease;
        }
        57.6%,
        100% {
          opacity: 0;
          transform: translateX(var(--_nudge));
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The travel goes, and with it the direction — there is no gentler way
           to say "rightwards" than moving rightwards. What is left has to carry
           the state change on its own: the detail crossfades over a dimmed
           index, and the back control still appears with it, because the thing
           worth preserving is that the previous view is still under there. */
        :root {
          --_nudge: 0px;
          --_lead: 0px;
        }

        .list {
          animation: none;
          transform: none;
        }

        .detail {
          transform: translateX(0);
          animation: detail-fade calc(var(--t) * 1.5) infinite;
        }

        .dim,
        .back,
        .title-list,
        .title-detail {
          animation-duration: calc(var(--t) * 1.5);
        }

        @keyframes detail-fade {
          0%,
          8% {
            opacity: 0;
            animation-timing-function: ease;
          }
          13.6%,
          54% {
            opacity: 1;
            animation-timing-function: ease;
          }
          59.6%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="frame"
      role="img"
      aria-label="A list view pushed aside as a detail view slides in from the right, then reversed exactly when the back control is used"
    >
      <div class="chrome">
        <span class="back">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path
              d="M15 5 8 12l7 7"
              stroke="currentColor"
              stroke-width="2.4"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </span>
        <span class="titles">
          <span class="title title-list">Inbox</span>
          <span class="title title-detail">Message</span>
        </span>
      </div>
      <div class="stage">
        <div class="pane list">
          <span class="row row-live"></span>
          <span class="row" style="width: 86%"></span>
          <span class="row" style="width: 94%"></span>
          <span class="row" style="width: 72%"></span>
          <span class="dim"></span>
        </div>
        <div class="pane detail">
          <span class="lead"></span>
          <span class="line" style="width: 92%"></span>
          <span class="line" style="width: 80%"></span>
          <span class="line" style="width: 88%"></span>
          <span class="chip"></span>
        </div>
      </div>
    </div>
  </body>
</html>