Back to the library

Toast — stack and demote

Variant: Stack and demote

Three toasts sharing one anchor, resolved as depth instead of a list: each newcomer takes the front and the elders step up and back — 9px then 17px of rise with 4.5% of scale per slot, off a bottom-centre origin so the shrink eats into the travel and each elder shows only a sliver of top edge above the toast in front. The elders begin moving 40ms before the newcomer does, because a stack has to look like it already knew something was coming; on the way out the survivors hold dead still until the leaver has finished fading, then step forward together. The queue drains front-first because the front is the only place an exit can be seen — an elder fading at depth is a change nobody witnesses. DOM order is arrival order, which is what keeps the newest toast painted on top with no z-index choreography at all.

toastoverlayliststaggertransitionloop

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>Toast — stack and demote</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 — 8s: three arrivals 1.2s apart, a hold, then the stack
           drains front-first. Every arrival costs 260ms, every demotion 240ms. */
        --cycle: 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);
      }

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

      .page {
        position: absolute;
        inset: 0;
        padding: 16px;
        display: grid;
        gap: 10px;
        align-content: start;
      }

      .bar {
        height: 10px;
        border-radius: 5px;
        background: var(--oat);
      }

      .page .head {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-bottom: 4px;
      }

      .page .dot {
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: var(--oat);
        flex: none;
      }

      .slot {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 12px;
        display: grid;
        justify-items: center;
      }

      /* All three toasts share one anchor and one bottom-centre origin, so a
         demotion is nothing but translate up and scale down. The origin makes
         the shrink eat into the travel — a card rises 9px but its top edge
         peeks only ~7px above the newcomer. DOM order is arrival order, which
         keeps the newest toast painted on top with no z-index choreography. */
      .toast {
        grid-area: 1 / 1;
        display: flex;
        align-items: center;
        gap: 8px;
        width: 172px;
        padding: 9px 12px;
        border-radius: calc(9px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
        box-shadow: 0 6px 18px color-mix(in srgb, var(--slate) 12%, transparent);
        transform-origin: 50% 100%;
        opacity: 0;
        transform: translateY(18px);
      }

      .toast .pip {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--clay);
        flex: none;
      }

      .toast .label {
        font-size: 11.5px;
        font-weight: 500;
        letter-spacing: -0.005em;
        white-space: nowrap;
      }

      .t1 {
        animation: t1 var(--t) linear infinite;
      }

      .t2 {
        animation: t2 var(--t) linear infinite;
      }

      .t3 {
        animation: t3 var(--t) linear infinite;
      }

      /* The elders start moving 40ms before the newcomer does — the stack has
         to look like it already knows something is coming. Departure runs the
         other way round: the survivors hold still until the leaver has
         finished fading, then step forward together. Demotions take the
         in-out curve because they are bodies being displaced; promotions ride
         the ease-out, because stepping forward is the stack answering a
         departure, and every response in this library decelerates. */
      @keyframes t1 {
        0%,
        3% {
          opacity: 0;
          transform: translateY(18px) scale(1);
          animation-timing-function: var(--ease-out);
        }
        6.25%,
        17.5% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: var(--ease-in-out);
        }
        20.5%,
        32.5% {
          opacity: 1;
          transform: translateY(-9px) scale(0.955);
          animation-timing-function: var(--ease-in-out);
        }
        35.5%,
        54.5% {
          opacity: 1;
          transform: translateY(-17px) scale(0.91);
          animation-timing-function: var(--ease-out);
        }
        57.5%,
        70.5% {
          opacity: 1;
          transform: translateY(-9px) scale(0.955);
          animation-timing-function: var(--ease-out);
        }
        73.5%,
        84% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: var(--ease-out);
        }
        86.25%,
        100% {
          opacity: 0;
          transform: translateY(10px) scale(1);
        }
      }

      @keyframes t2 {
        0%,
        18% {
          opacity: 0;
          transform: translateY(18px) scale(1);
          animation-timing-function: var(--ease-out);
        }
        21.25%,
        32.5% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: var(--ease-in-out);
        }
        35.5%,
        54.5% {
          opacity: 1;
          transform: translateY(-9px) scale(0.955);
          animation-timing-function: var(--ease-out);
        }
        57.5%,
        68% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: var(--ease-out);
        }
        70.25%,
        100% {
          opacity: 0;
          transform: translateY(10px) scale(1);
        }
      }

      @keyframes t3 {
        0%,
        33% {
          opacity: 0;
          transform: translateY(18px) scale(1);
          animation-timing-function: var(--ease-out);
        }
        36.25%,
        52% {
          opacity: 1;
          transform: translateY(0) scale(1);
          animation-timing-function: var(--ease-out);
        }
        54.25%,
        100% {
          opacity: 0;
          transform: translateY(10px) scale(1);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The stack stops being spatial: the three toasts take turns in the
           front slot as evenly spaced, non-overlapping crossfades — the RM
           schedule is its own, so the queue keeps running for the whole
           slowed cycle instead of going dark after the last exit. */
        .t1 {
          animation-name: t1-rm;
        }

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

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

        .t1,
        .t2,
        .t3 {
          transform: translateY(0);
          animation-duration: calc(var(--t) * 1.6);
        }

        @keyframes t1-rm {
          0%,
          3% {
            opacity: 0;
            animation-timing-function: ease;
          }
          6.25%,
          28% {
            opacity: 1;
            animation-timing-function: ease;
          }
          31%,
          100% {
            opacity: 0;
          }
        }

        @keyframes t2-rm {
          0%,
          33% {
            opacity: 0;
            animation-timing-function: ease;
          }
          36.25%,
          61% {
            opacity: 1;
            animation-timing-function: ease;
          }
          64%,
          100% {
            opacity: 0;
          }
        }

        @keyframes t3-rm {
          0%,
          66% {
            opacity: 0;
            animation-timing-function: ease;
          }
          69.25%,
          94% {
            opacity: 1;
            animation-timing-function: ease;
          }
          97%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="frame"
      role="img"
      aria-label="Three toasts arriving in sequence, each pushing the older ones back into a depth stack, then leaving front-first"
    >
      <div class="page">
        <div class="head">
          <span class="dot"></span>
          <span class="bar" style="width: 42%"></span>
        </div>
        <span class="bar" style="width: 88%"></span>
        <span class="bar" style="width: 74%"></span>
        <span class="bar" style="width: 81%"></span>
      </div>
      <div class="slot">
        <div class="toast t1">
          <span class="pip"></span>
          <span class="label">Link copied</span>
        </div>
        <div class="toast t2">
          <span class="pip"></span>
          <span class="label">Photo uploaded</span>
        </div>
        <div class="toast t3">
          <span class="pip"></span>
          <span class="label">Draft saved</span>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.