Back to the library

Anchored — grown from the trigger

Variant: From the trigger

The base case for every surface that has a parent on screen, and the exact opposite of the dialog in the overlay family: a dialog is anchored to nothing and scales from its own centre, while a menu belongs to a button and has to come out of it. The whole build is that the surface and its rows are separate layers — only the surface scales, which lets it open faster on Y than on X, the way a menu actually unfurls, without shearing a single glyph. Scale the menu as one element and the type stretches on the way in, and that is the tell separating a zoom from an opening. Two smaller things carry the rest: the rows come down from above rather than up from below, because contents travelling against their own container read as two animations sharing a box, and the trigger stays visibly held for as long as the surface is up, since a button that pops back to rest has orphaned the thing it opened.

transitionoverlaycontrolentrance

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>Anchored — grown from the trigger</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 surface opens in 166ms and closes in 125ms: a
           dropdown sits in the 150-250ms band, and leaving is always cheaper
           than arriving. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        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(224px, 46vw, 330px);
        height: min(66vh, 72vw, 224px);
        border-radius: calc(12px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
        overflow: hidden;
      }

      .toolbar {
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        height: 32px;
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 0 10px;
        border-bottom: 1px solid color-mix(in srgb, var(--slate) 8%, transparent);
      }

      .ghost {
        height: 7px;
        border-radius: 4px;
        background: var(--oat);
      }

      /* The trigger stays visibly held for as long as the menu is up: the button
         is still the parent of what is on screen, and a trigger that pops back
         to rest orphans the surface it opened. */
      .trigger {
        display: flex;
        align-items: center;
        gap: 5px;
        height: 20px;
        padding: 0 7px;
        border-radius: calc(6px * var(--round));
        font-size: 11px;
        font-weight: 550;
        line-height: 1;
        letter-spacing: -0.01em;
        background: color-mix(in srgb, var(--slate) 6%, transparent);
        animation: trigger var(--t) infinite;
      }

      .chev {
        width: 9px;
        height: 9px;
        display: block;
        color: color-mix(in srgb, var(--slate) 50%, transparent);
        animation: chev var(--t) infinite;
      }

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

      .page {
        position: absolute;
        inset: 32px 0 0;
        padding: 14px;
        display: grid;
        gap: 9px;
        align-content: start;
      }

      .page .row {
        height: 8px;
        border-radius: 4px;
        background: color-mix(in srgb, var(--oat) 62%, var(--paper));
      }

      /* The menu is aligned to the trigger's left edge, which is also where it
         is going to grow from. */
      .menu {
        position: absolute;
        left: 10px;
        top: 37px;
        width: 112px;
      }

      /* The surface and the contents are separate layers, and that is the whole
         build. Only this one scales — so it can open faster on Y than on X, the
         way a menu actually unfurls, without shearing a single glyph. Scale the
         menu as one element and the type stretches on the way in, which is the
         tell that separates a zoom from an opening. */
      .surface {
        position: absolute;
        inset: 0;
        border-radius: calc(9px * var(--round));
        background: var(--ivory);
        border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
        box-shadow: 0 10px 24px color-mix(in srgb, var(--slate) 14%, transparent);
        transform-origin: top left;
        transform: scale(0.94, 0.76);
        opacity: 0;
        animation: surface var(--t) infinite;
      }

      .items {
        position: relative;
        padding: 5px 0;
        display: grid;
      }

      .item {
        display: flex;
        align-items: center;
        gap: 6px;
        height: 21px;
        padding: 0 9px;
        font-size: 11px;
        line-height: 1;
        letter-spacing: -0.005em;
        opacity: 0;
        animation: item var(--t) infinite;
        animation-delay: calc(var(--_i) * 26ms / var(--speed));
      }

      .item .tick {
        width: 5px;
        height: 5px;
        flex: none;
        border-radius: 50%;
        background: transparent;
      }

      .item.on .tick {
        background: var(--clay);
      }

      .item.on {
        font-weight: 550;
      }

      @keyframes surface {
        0%,
        11% {
          opacity: 0;
          transform: scale(0.94, 0.76);
          animation-timing-function: var(--ease-out);
        }
        14.2%,
        60% {
          opacity: 1;
          transform: scale(1, 1);
          animation-timing-function: ease;
        }
        62.4%,
        100% {
          opacity: 0;
          transform: scale(0.97, 0.88);
        }
      }

      /* Rows come down from above rather than up from below, because the surface
         beneath them is opening downward and contents that travel against their
         own container read as two animations sharing a box. */
      @keyframes item {
        0%,
        12.6% {
          opacity: 0;
          transform: translateY(-3px);
          animation-timing-function: var(--ease-out);
        }
        15.4%,
        59.6% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: ease;
        }
        61.2%,
        100% {
          opacity: 0;
          transform: translateY(-2px);
        }
      }

      @keyframes trigger {
        0%,
        10% {
          transform: scale(1);
          background: color-mix(in srgb, var(--slate) 6%, transparent);
          animation-timing-function: var(--ease-out);
        }
        11.7% {
          transform: scale(0.965);
          background: color-mix(in srgb, var(--clay) 17%, transparent);
          animation-timing-function: var(--ease-out);
        }
        15.2%,
        60% {
          transform: scale(1);
          background: color-mix(in srgb, var(--clay) 17%, transparent);
          animation-timing-function: ease;
        }
        63.5%,
        100% {
          transform: scale(1);
          background: color-mix(in srgb, var(--slate) 6%, transparent);
        }
      }

      @keyframes chev {
        0%,
        11% {
          transform: rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        14.2%,
        60% {
          transform: rotate(180deg);
          animation-timing-function: var(--ease-out);
        }
        62.4%,
        100% {
          transform: rotate(0deg);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The scale goes and the origin goes with it, which costs this snippet
           its subject — so what is left has to carry the anchoring on its own.
           The trigger keeps its held tint for exactly as long as the surface is
           up, and that pairing is the only remaining statement that these two
           things belong to each other. */
        .surface {
          animation-name: surface-rm;
          transform: none;
        }

        .item {
          animation-name: item-rm;
          animation-delay: 0s;
        }

        .chev {
          animation-name: chev-rm;
        }

        .trigger,
        .surface,
        .item,
        .chev {
          animation-duration: calc(var(--t) * 1.7);
        }

        .trigger {
          animation-name: trigger-rm;
        }

        @keyframes surface-rm {
          0%,
          11% {
            opacity: 0;
            animation-timing-function: ease;
          }
          14.2%,
          60% {
            opacity: 1;
            animation-timing-function: ease;
          }
          62.4%,
          100% {
            opacity: 0;
          }
        }

        @keyframes item-rm {
          0%,
          12.6% {
            opacity: 0;
            animation-timing-function: ease;
          }
          15.4%,
          59.6% {
            opacity: 1;
            animation-timing-function: ease;
          }
          61.2%,
          100% {
            opacity: 0;
          }
        }

        @keyframes chev-rm {
          0%,
          11% {
            transform: rotate(0deg);
            animation-timing-function: steps(1, end);
          }
          14.2%,
          60% {
            transform: rotate(180deg);
            animation-timing-function: steps(1, end);
          }
          62.4%,
          100% {
            transform: rotate(0deg);
          }
        }

        @keyframes trigger-rm {
          0%,
          10% {
            background: color-mix(in srgb, var(--slate) 6%, transparent);
            animation-timing-function: ease;
          }
          15.2%,
          60% {
            background: color-mix(in srgb, var(--clay) 17%, transparent);
            animation-timing-function: ease;
          }
          63.5%,
          100% {
            background: color-mix(in srgb, var(--slate) 6%, transparent);
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="frame"
      role="img"
      aria-label="A dropdown menu opening out of the corner of the button that triggered it, and closing back into it"
    >
      <div class="page">
        <div class="row" style="width: 86%"></div>
        <div class="row" style="width: 72%"></div>
        <div class="row" style="width: 90%"></div>
        <div class="row" style="width: 61%"></div>
        <div class="row" style="width: 78%"></div>
      </div>

      <div class="toolbar">
        <span class="trigger">
          View
          <span class="chev">
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path
                d="m5 9 7 7 7-7"
                stroke="currentColor"
                stroke-width="2.6"
                stroke-linecap="round"
                stroke-linejoin="round"
              />
            </svg>
          </span>
        </span>
        <span class="ghost" style="width: 38px"></span>
        <span class="ghost" style="width: 24px"></span>
      </div>

      <div class="menu">
        <div class="surface"></div>
        <div class="items">
          <span class="item on" style="--_i: 0"><span class="tick"></span>Grid</span>
          <span class="item" style="--_i: 1"><span class="tick"></span>List</span>
          <span class="item" style="--_i: 2"><span class="tick"></span>Columns</span>
          <span class="item" style="--_i: 3"><span class="tick"></span>Timeline</span>
        </div>
      </div>
    </div>
  </body>
</html>