Back to the library

Anchored — the placement flips

Variant: Placement flip

One trigger, one surface, opened twice: below the row while there is room, and above it once the list has scrolled that row against the bottom edge. The boundary is a real clip rather than a drawn line, so the second placement is forced rather than asserted. What flips is the transform-origin and nothing else — the surface still grows out of its beak, so both openings point back at the same 18px button and neither reads as a different component arriving. Both cost 210ms in and 163ms out for the same reason. The beak is a child of the surface rather than a sibling parked beside it, which makes it impossible for the two to disagree on any frame, and its offset is not a tuned number: it is the row’s padding plus half the trigger, which is the trigger’s centre measured from the edge they are both aligned to. The popovers are children of the row itself, so a scroll cannot leave a surface stranded where the anchor used to be.

transitionoverlaycontrollist

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 — the placement flips</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 — 6.8s. Both openings cost 210ms and both closings 163ms: the
           flipped placement is a different position, not a different component,
           and anything that made it feel slower would be saying otherwise. */
        --cycle: 6.8s;
        --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);

        /* The list's geometry. The scroll offset is the distance between the
           anchor row's two resting places and nothing else, so moving a row
           re-times nothing. */
        --_pitch: 26px;
        --_row: 21px;
        --_scroll: 78px;

        /* Where the beak sits inside the surface — and therefore where the
           surface grows from, on both placements. It is not a tuned number: it
           is the row's right padding plus half the trigger, which is the
           trigger's centre measured from the edge both are aligned to. */
        --_beak: 16px;
      }

      * {
        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);
        display: grid;
        place-items: center;
        border-radius: calc(12px * var(--round));
        background: var(--ivory);
      }

      /* The boundary is the whole subject, so it is a real clip and not a
         drawn line: whatever will not fit inside this box genuinely does not
         get shown. */
      .stage {
        position: relative;
        width: 172px;
        height: 144px;
        border-radius: calc(10px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
        overflow: hidden;
      }

      .list {
        position: absolute;
        left: 0;
        right: 0;
        top: 8px;
        animation: scroll var(--t) infinite;
      }

      .row {
        position: relative;
        height: var(--_row);
        margin-bottom: calc(var(--_pitch) - var(--_row));
        display: flex;
        align-items: center;
        gap: 7px;
        padding: 0 7px;
        margin-left: 10px;
        margin-right: 10px;
        border-radius: calc(5px * var(--round));
        background: color-mix(in srgb, var(--oat) 26%, var(--paper));
        font-size: 11px;
        line-height: 1;
        letter-spacing: -0.005em;
      }

      .row .dot {
        width: 5px;
        height: 5px;
        flex: none;
        border-radius: 50%;
        background: color-mix(in srgb, var(--slate) 22%, transparent);
      }

      .anchor {
        background: color-mix(in srgb, var(--oat) 52%, var(--paper));
      }

      .trigger {
        margin-left: auto;
        width: 18px;
        height: 15px;
        display: grid;
        place-items: center;
        border-radius: calc(4px * var(--round));
        color: color-mix(in srgb, var(--slate) 55%, transparent);
        background: transparent;
        animation: trigger var(--t) infinite;
      }

      .trigger svg {
        width: 12px;
        height: 12px;
        display: block;
      }

      /* The popover belongs to its row, but the rows after it are positioned
         siblings and would otherwise paint over it: a later sibling wins on
         equal footing, and being nested inside an earlier one does not help. */
      .pop {
        position: absolute;
        z-index: 2;
        right: 0;
        width: 116px;
        height: 60px;
        padding: 9px;
        display: grid;
        align-content: start;
        gap: 6px;
        border-radius: calc(8px * var(--round));
        background: var(--ivory);
        border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
        box-shadow: 0 8px 20px color-mix(in srgb, var(--slate) 15%, transparent);
        opacity: 0;
      }

      /* The beak is a child of the surface rather than a sibling parked beside
         it, which is what makes it impossible for the two to disagree: it
         scales, moves and fades on exactly the frames the surface does, so it
         is still touching the trigger on every one of them. */
      .pop::after {
        content: '';
        position: absolute;
        right: var(--_beak);
        width: 7px;
        height: 7px;
        margin-right: -3.5px;
        background: var(--ivory);
        border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
        transform: rotate(45deg);
      }

      /* Below the trigger: the surface hangs off its own top edge, at the beak. */
      .pop.down {
        top: calc(100% + 7px);
        transform-origin: calc(100% - var(--_beak)) 0;
        transform: translateY(-4px) scale(0.94);
        animation: pop-down var(--t) infinite;
      }

      .pop.down::after {
        top: 0;
        margin-top: -4px;
        border-right: 0;
        border-bottom: 0;
      }

      /* Above it: the same surface standing on its own bottom edge. The origin
         is the only thing that changed, and it is the only thing that had to. */
      .pop.up {
        bottom: calc(100% + 7px);
        transform-origin: calc(100% - var(--_beak)) 100%;
        transform: translateY(4px) scale(0.94);
        animation: pop-up var(--t) infinite;
      }

      .pop.up::after {
        bottom: 0;
        margin-bottom: -4px;
        border-left: 0;
        border-top: 0;
      }

      .pop .title {
        font-size: 11px;
        font-weight: 600;
        line-height: 1;
        letter-spacing: -0.01em;
      }

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

      .pop .line.key {
        background: var(--clay);
      }

      /* The list is a transform rather than a scroll position, so the popovers
         ride it for free: they are children of the row they belong to, which is
         the only build where a scroll cannot leave a surface behind. */
      @keyframes scroll {
        0%,
        37% {
          transform: translateY(calc(-1 * var(--_scroll)));
          animation-timing-function: var(--ease-in-out);
        }
        42%,
        80% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        85%,
        100% {
          transform: translateY(calc(-1 * var(--_scroll)));
        }
      }

      @keyframes pop-down {
        0%,
        6.5% {
          opacity: 0;
          transform: translateY(-4px) scale(0.94);
          animation-timing-function: var(--ease-out);
        }
        9.6%,
        32% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: ease;
        }
        34.4%,
        100% {
          opacity: 0;
          transform: translateY(-3px) scale(0.96);
        }
      }

      @keyframes pop-up {
        0%,
        46% {
          opacity: 0;
          transform: translateY(4px) scale(0.94);
          animation-timing-function: var(--ease-out);
        }
        49.1%,
        74% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: ease;
        }
        76.4%,
        100% {
          opacity: 0;
          transform: translateY(3px) scale(0.96);
        }
      }

      @keyframes trigger {
        0%,
        5% {
          background: transparent;
          animation-timing-function: ease;
        }
        6.5%,
        33% {
          background: color-mix(in srgb, var(--clay) 18%, transparent);
          animation-timing-function: ease;
        }
        35.5%,
        44.5% {
          background: transparent;
          animation-timing-function: ease;
        }
        46%,
        75% {
          background: color-mix(in srgb, var(--clay) 18%, transparent);
          animation-timing-function: ease;
        }
        77.5%,
        100% {
          background: transparent;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Scale and travel go; the flip stays, because the flip is not motion.
           It is a placement decision, and a popover drawn over the boundary it
           was supposed to respect is wrong in a still frame too. The scroll
           becomes a cut for the same reason it does everywhere else in this
           library: a list moving by itself is the one thing reduced motion is
           actually being asked about. */
        .pop.down {
          animation-name: pop-down-rm;
        }

        .pop.up {
          animation-name: pop-up-rm;
        }

        .list {
          animation-name: scroll-step;
        }

        .list,
        .pop,
        .trigger {
          animation-duration: calc(var(--t) * 1.6);
        }

        @keyframes scroll-step {
          0%,
          37% {
            transform: translateY(calc(-1 * var(--_scroll)));
            animation-timing-function: steps(1, end);
          }
          42%,
          80% {
            transform: translateY(0);
            animation-timing-function: steps(1, end);
          }
          85%,
          100% {
            transform: translateY(calc(-1 * var(--_scroll)));
          }
        }

        @keyframes pop-down-rm {
          0%,
          6.5% {
            opacity: 0;
            animation-timing-function: ease;
          }
          9.6%,
          32% {
            opacity: 1;
            animation-timing-function: ease;
          }
          34.4%,
          100% {
            opacity: 0;
          }
        }

        @keyframes pop-up-rm {
          0%,
          46% {
            opacity: 0;
            animation-timing-function: ease;
          }
          49.1%,
          74% {
            opacity: 1;
            animation-timing-function: ease;
          }
          76.4%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="frame"
      role="img"
      aria-label="One trigger opening the same popover twice: below it when there is room, and flipped above it once the list has scrolled the row against the bottom edge"
    >
      <div class="stage">
        <div class="list">
          <div class="row"><span class="dot"></span>Sessions</div>
          <div class="row"><span class="dot"></span>Referrers</div>
          <div class="row"><span class="dot"></span>Devices</div>
          <div class="row"><span class="dot"></span>Countries</div>
          <div class="row anchor">
            <span class="dot"></span>Retention
            <span class="trigger">
              <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
                <circle cx="5" cy="12" r="2.1" />
                <circle cx="12" cy="12" r="2.1" />
                <circle cx="19" cy="12" r="2.1" />
              </svg>
            </span>
            <div class="pop down">
              <span class="title">Retention</span>
              <span class="line key" style="width: 62%"></span>
              <span class="line" style="width: 84%"></span>
            </div>
            <div class="pop up">
              <span class="title">Retention</span>
              <span class="line key" style="width: 62%"></span>
              <span class="line" style="width: 84%"></span>
            </div>
          </div>
          <div class="row"><span class="dot"></span>Funnels</div>
          <div class="row"><span class="dot"></span>Cohorts</div>
          <div class="row"><span class="dot"></span>Exports</div>
        </div>
      </div>
    </div>
  </body>
</html>