Library / Tutorial

Toast stack,
step by step.

The little messages a web app shows in a corner of the screen, done properly. They arrive and stack like a deck, fan out into a list when you point at them, and say where they are from, what happened and what you can do next. Swipe one away, undo before its ring runs out, watch an upload turn into done, retry an error. Every countdown waits while you read. Six stages, each a live page you can use.

6Stages
3Looks
0Libraries

This is the whole build of Toast stack, in the order it was written: the little messages a web app shows in a corner of the screen, for a made-up product, Calmdeck. They arrive, stack like a deck of cards and fan out into a list when you point at them; you can swipe them away, and every countdown waits while you read. Each stage below is a live page you can use: press the buttons, point at the deck, throw a toast off the edge. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work from the start: index.html for the page and the toast, style.css for the tokens and the layout, and app.js for the behaviour. From stage 3 there is a fourth, spring.js. There is no library and no build step; nothing is sent anywhere.

1 · The frame, a toast and three looks

The first stage is the whole page in a 432 × 768 frame that app.js scales to fit the screen; add ?full=1 and the same markup is a real page at the size of your window, with the toasts in the screen's corner. The page has four buttons that will raise toasts, and the toasts' corner: a <section> with a label (it tells a screen reader what it is and how to get there, Alt+T) holding an ordered list, the deck.

index.html — the page's four buttons (written for frost in this stage)
<section class="actions" aria-label="Try the toasts">
  <button class="act" type="button" data-toast="a"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6.5h14v9H10l-4 3v-3H5z"/></svg><b>Message</b><span>from Aysel</span></button>
  <button class="act" type="button" data-toast="b"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v10H4zM4 10.5h16M7 14h4"/></svg><b>Payment</b><span>Wallet</span></button>
  <button class="act" type="button" data-toast="c"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v12H4zM4 15l4.5-4.5 4 4 2.5-2.5L20 17M15 9.5h.1"/></svg><b>Upload</b><span>12 photos</span></button>
  <button class="act" type="button" data-toast="d"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6h14v13H5zM5 10h14M9 4v3M15 4v3"/></svg><b>Invite</b><span>Calendar</span></button>
</section>
index.html — the toasts' corner
<!-- the toasts' corner: a labelled region (Alt+T moves the focus into it); each toast is a list item -->
<section class="toasts" aria-label="Notifications (Alt+T)" tabindex="-1">
  <ol class="deck">

A toast is a list item with two layers. .glass is the card's surface, hidden from screen readers; it also holds a fill (for a job in progress) and a thin timer line. .body sits over it: a meta line (where it is from and when), a row with a picture, the words and a close button, and up to two actions. The picture is one of three things: a status icon (a ring, a tick, an exclamation, an undo arrow, all in one SVG), a face with initials, or a thing drawn in line. In this stage there is one toast, written by hand.

index.html — one toast, by hand
<li class="toast" data-kind="info" data-app data-pic="face" data-actions role="status" tabindex="0" style="--icon: linear-gradient(180deg, #64E07F, #2DBE4E); --hue: 300">
  <div class="glass" aria-hidden="true"><i class="fill"></i><i class="timer"></i></div>   <!-- the card's surface -->
  <div class="body">
    <p class="meta"><span class="app"><i class="app-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 6.5h14v9H10l-4 3v-3H5z"/></svg></i><b class="app-name">Messages</b></span><time class="when">now</time></p>
    <div class="row">
      <span class="pic" aria-hidden="true">
        <svg class="status" viewBox="0 0 32 32">
          <circle class="ring-track" cx="16" cy="16" r="13"/><circle class="ring" cx="16" cy="16" r="13" pathLength="1"/>
          <path class="i-done" pathLength="1" d="M10.5 16.5l3.6 3.6 7.4-8"/>
          <path class="i-error" d="M16 9.5v8M16 22v.4"/>
          <path class="i-undo" d="M11 14.5h7.5a4 4 0 0 1 0 8H14M11 14.5l3-3M11 14.5l3 3"/>
        </svg>
        <b class="face">AK</b>
        <svg class="thing" viewBox="0 0 40 40"><path/></svg>
      </span>
      <div class="text"><p class="t-title">Aysel Karimova</p><p class="t-desc">Can you look at the deck before 4?</p><p class="t-code"></p></div>
      <button class="t-close" type="button" aria-label="Dismiss"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg></button>
    </div>
    <div class="t-actions"><button class="t-action" type="button">Reply</button><button class="t-action t-alt" type="button">Mark as read</button></div>
  </div>
</li>

Every colour, shadow and material comes from a CSS variable, in three layers. At the bottom are the primitives: values with no meaning, one family per look.

style.css — the primitives
:root {
  --n-1000: #000; --n-0: #fff;
  --w-6: rgb(255 255 255 / .06); --w-12: rgb(255 255 255 / .12); --w-18: rgb(255 255 255 / .18); --w-40: rgb(255 255 255 / .4);
  --b-25: rgb(0 0 0 / .25); --b-40: rgb(0 0 0 / .4); --b-70: rgb(0 0 0 / .7);
  /* frost: a soft wallpaper, frosted white, system blue, green and red */
  --fr-ink: #0B0B0F; --fr-ink-2: rgb(60 60 67 / .72); --fr-blue: #0A84FF; --fr-green: #30C15A; --fr-red: #F2453D; --fr-bg: #ECEFF7;
  /* mono: near-black, greys, one green, one amber, one red */
  --mo-950: #0A0A0A; --mo-900: #0F0F10; --mo-850: #161617; --mo-800: #1C1C1D; --mo-700: #262626; --mo-600: #333335; --mo-400: #8F8F8F; --mo-300: #A1A1A1; --mo-100: #EDEDED;
  --mo-green: #45D483; --mo-amber: #F5A623; --mo-red: #F75F5F;
  /* ivory: paper, ink, and a gold hairline */
  --iv-50: #FBF8F2; --iv-100: #F4EFE6; --iv-200: #E9E1D2; --iv-ink: #141414; --iv-ink-2: #5C5548; --iv-ink-3: #8A7A5C; --iv-gold: #A8854A; --iv-red: #9E3B2E;

  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --system: -apple-system, BlinkMacSystemFont, 'Geist', ui-sans-serif, system-ui, sans-serif;
  --serif: 'Instrument Serif', 'Times New Roman', serif;
  --mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

On top are the semantic tokens: what a value is for. The page, the toast's surface, its type, its buttons and its kinds get their own names, and a whole set is written once per look on [data-look]. Three looks ship from the first stage, and each is its own post: frost (frosted system banners on a soft wallpaper), mono (a developer tool at night, black and grey) and ivory (an editorial page: ivory paper, a serif, a gold hairline). Here is frost.

style.css — the semantic tokens of the frost look
/* A · FROST: system banners on a soft wallpaper. Deep frosted white, an app icon and its name, actions as pills; the
   deck hangs from the top of the screen and a banner drops into it */
[data-look="frost"] {
  --title-face: var(--system); --title-w: 700; --title-size: 44px; --em-face: var(--system); --em-style: normal; --em-w: 300; --em-size: 1em; --em: #3D4466;
  --toast-face: var(--system); --toast-w: 600; --title-px: 15px; --desc-px: 14px; --meta-face: var(--system); --meta-case: uppercase; --meta-track: .02em;
  --page: var(--fr-bg);
  --field-bg: radial-gradient(60% 42% at 18% 22%, #A9CCFF 0%, transparent 62%), radial-gradient(52% 40% at 88% 34%, #E3BBFF 0%, transparent 64%),
              radial-gradient(70% 46% at 50% 100%, #FFCDB8 0%, transparent 66%), radial-gradient(40% 30% at 70% 70%, #BDEBFF 0%, transparent 70%);
  --on-field: var(--fr-ink); --on-field-muted: rgb(40 44 70 / .66); --chip: var(--fr-ink); --on-chip: var(--n-0);
  --avatar-ring: var(--fr-ink); --lockup: brightness(0); --light: rgb(255 255 255 / .55);
  --surface: rgb(255 255 255 / .56); --surface-filter: blur(30px) saturate(1.9);
  --surface-rim: inset 0 0 0 .5px rgb(255 255 255 / .75), 0 0 0 .5px rgb(0 0 0 / .06);
  --surface-drop: 0 14px 34px -12px rgb(30 40 90 / .28), 0 2px 6px rgb(30 40 90 / .08); --toast-r: 22px;
  --on-toast: var(--fr-ink); --on-toast-muted: var(--fr-ink-2); --ok: var(--fr-green); --bad: var(--fr-red); --busy: var(--fr-blue);
  --track: rgb(0 0 0 / .08); --fill: rgb(10 132 255 / .1); --timer: transparent;
  --code: rgb(0 0 0 / .05); --code-edge: transparent; --on-code: var(--fr-ink);
  --btn: var(--fr-blue); --on-btn: var(--n-0); --btn-alt: rgb(0 0 0 / .06); --on-btn-alt: var(--fr-ink); --btn-alt-edge: transparent;
  --btn-r: 999px; --btn-case: none; --btn-track: 0; --thing: var(--fr-ink);
  --act: rgb(255 255 255 / .5); --act-filter: blur(26px) saturate(1.8); --act-rim: inset 0 0 0 .5px rgb(255 255 255 / .7), 0 10px 26px -14px rgb(30 40 90 / .3);
  --act-press: scale(.97); --act-edge: transparent; --on-act: var(--fr-ink); --on-act-muted: var(--fr-ink-2); --act-face: var(--system); --act-w: 600;
  --pill: rgb(255 255 255 / .6); --pill-edge: rgb(255 255 255 / .7); --on-pill: var(--fr-ink); --on-pill-muted: var(--fr-ink-2); --pill-icon: var(--fr-ink); --on-pill-icon: var(--n-0);
}

The toast then names the tokens it uses, and its rules read only those names. Changing a look never touches a rule.

style.css — the toast's component tokens
.toast    { --t-surface: var(--surface); --t-filter: var(--surface-filter); --t-rim: var(--surface-rim); --t-drop: var(--surface-drop); --t-r: var(--toast-r);
            --t-face: var(--toast-face); --t-w: var(--toast-w); --t-title: var(--title-px); --t-desc: var(--desc-px);
            --t-meta-face: var(--meta-face); --t-meta-case: var(--meta-case); --t-meta-track: var(--meta-track);
            --t-ink: var(--on-toast); --t-dim: var(--on-toast-muted); --t-ok: var(--ok); --t-bad: var(--bad); --t-busy: var(--busy);
            --t-track: var(--track); --t-fill: var(--fill); --t-timer: var(--timer); --t-code: var(--code); --t-code-edge: var(--code-edge); --t-on-code: var(--on-code);
            --t-btn: var(--btn); --t-on-btn: var(--on-btn); --t-alt: var(--btn-alt); --t-on-alt: var(--on-btn-alt); --t-alt-edge: var(--btn-alt-edge);
            --t-btn-r: var(--btn-r); --t-btn-case: var(--btn-case); --t-btn-track: var(--btn-track); --t-thing: var(--thing); }

A script in the page's head picks the look from ?look=frost, mono or ivory before the first paint, so the page never flashes the wrong one.

index.html — the look, before the first paint
(() => {
  const html = document.documentElement, q = new URLSearchParams(location.search);
  const look = { a: 'frost', b: 'mono', c: 'ivory' }[q.get('look')] || q.get('look');
  html.dataset.look = ['frost', 'mono', 'ivory'].includes(look) ? look : 'frost';
  if (q.get('full') === '1') html.dataset.full = '';
})();

The corner says which edge the deck hangs from. It stands on the bottom, except in frost, where banners drop from the top of the screen like a phone's. The answer is written into a custom property, --anchor, so the stylesheet stays the only place that decides it; from stage 3 the script reads it.

style.css — the corner, anchored at the bottom or the top
/* ---- 5. the toasts' corner: anchored at the bottom (or, for frost, the top: --anchor, read by app.js); app.js places
        every toast in it ---- */
.toasts { --anchor: bottom; position: absolute; left: 22px; right: 22px; bottom: 138px; z-index: 6; height: var(--deck-h, 72px); outline: none; }
.deck { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
[data-look="frost"] .toasts { --anchor: top; top: 150px; bottom: auto; }
style.css — a toast: the surface, and the body over it
/* ---- 6. a toast: the surface (sprung by app.js), the body over it ---- */
.toast { position: absolute; left: 0; right: 0; bottom: 0; color: var(--t-ink); touch-action: pan-y; transform-origin: 50% 100%; font-family: var(--t-face); }
[data-look="frost"] .toast { top: 0; bottom: auto; transform-origin: 50% 0; }
.toast:focus-visible { outline: none; }
.toast:focus-visible .glass { box-shadow: var(--t-rim), var(--t-drop), 0 0 0 2px var(--t-ink); }
.glass { position: absolute; inset: 0; overflow: hidden; border-radius: var(--t-r); pointer-events: none;
  background: var(--t-surface); box-shadow: var(--t-rim), var(--t-drop); -webkit-backdrop-filter: var(--t-filter); backdrop-filter: var(--t-filter); }
.fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--t-fill); opacity: 0; }
[data-look="mono"] .fill, [data-look="ivory"] .fill { top: auto; height: 2px; }                     /* a line along the bottom */
[data-look="ivory"] .fill { height: 1px; bottom: 6px; left: 6px; }
.timer { position: absolute; left: 16px; right: 16px; bottom: 7px; height: 1px; background: var(--t-timer); transform-origin: 0 50%; opacity: 0; }
.body { position: relative; display: grid; gap: 8px; padding: 13px 12px 13px 15px; }

The picture is three elements in one grid cell, and the toast's data-pic shows one of them; with no picture it shows the status icon, and data-kind decides which mark is drawn in it.

style.css — the picture: a status icon, a face, or a thing
/* the picture: a status icon (done, in progress, undo, error), a face (initials), or a thing drawn in line */
.pic { position: relative; flex: none; width: 38px; height: 38px; display: grid; place-items: center; }
[data-look="ivory"] .pic { width: 44px; height: 44px; }
.pic > * { grid-area: 1 / 1; display: none; }
.toast:not([data-pic]) .status, .toast[data-pic="thing"] .thing { display: block; }
.status { width: 34px; height: 34px; overflow: visible; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.ring-track { stroke: var(--t-track); stroke-width: 2.2; }
.ring { stroke: var(--t-busy); stroke-width: 2.2; stroke-dasharray: 1; stroke-dashoffset: 1; transform: rotate(-90deg); transform-origin: 16px 16px; }
.i-done, .i-error, .i-undo { stroke-width: 2.4; opacity: 0; }
.i-done { stroke: var(--t-ok); stroke-dasharray: 1; stroke-dashoffset: 1; }
.i-error { stroke: var(--t-bad); }
.i-undo { stroke: var(--t-ink); stroke-width: 2; }
.toast[data-kind="done"] .i-done, .toast[data-kind="error"] .i-error, .toast[data-kind="undo"] .i-undo { opacity: 1; }
.toast[data-kind="done"] .ring-track, .toast[data-kind="error"] .ring-track { stroke: none; }
[data-look="ivory"] .status { width: 36px; height: 36px; }
[data-look="ivory"] .status path { stroke-width: 1.6; }
[data-look="ivory"] .ring, [data-look="ivory"] .ring-track { stroke-width: 1.2; }
.face { width: 38px; height: 38px; border-radius: 50%; place-items: center; font-family: var(--sans); font-size: 14px; font-weight: 650; letter-spacing: .02em; color: #fff;
  background: linear-gradient(140deg, hsl(var(--hue, 260) 80% 66%), hsl(calc(var(--hue, 260) + 40) 75% 52%)); box-shadow: inset 0 0 0 .5px rgb(0 0 0 / .1); }
.toast[data-pic="face"] .face { display: grid; }

With ?full=1 the toasts live where they do on a real site: fixed in the screen's corner, 380 px wide, top right for frost and bottom right for the others; on a phone (under 600 px) they go across the screen, 10 px from its edges and clear of the notch and the home bar (env(safe-area-inset-…)).

style.css — ?full=1: the toasts in the screen's corner
/* ---- 8. ?full=1: the page on its own, at the real viewport. The toasts live in the screen's corner: top right for
        frost (bottom right for the others) on a wide screen, across the screen on a phone ---- */
[data-full] body { overflow: auto; }
[data-full] .stage { height: auto; min-height: 100dvh; overflow: visible; }
[data-full] .frame { position: relative; left: auto; top: auto; width: 100%; min-height: 100dvh; height: auto; margin: 0; padding: clamp(40px, 10vh, 120px) 16px 180px; }
[data-full] .amb { position: fixed; }
[data-full] .title { position: static; }
[data-full] .actions { position: static; width: min(100%, 540px); margin: 36px auto 0; }
[data-full] .toasts { position: fixed; left: auto; right: 24px; bottom: 24px; width: 380px; }
[data-full][data-look="frost"] .toasts { top: 16px; bottom: auto; right: 16px; }
@media (max-width: 599px) {
  [data-full] .toasts { left: 10px; right: 10px; bottom: max(10px, env(safe-area-inset-bottom)); width: auto; }
  [data-full][data-look="frost"] .toasts { top: max(10px, env(safe-area-inset-top)); bottom: auto; }
}

For now nothing moves, so a short block at the end of the stylesheet lets the corner be as tall as its toast and draws the status marks at once. Stage 3 deletes most of it.

style.css — stages 1–2 only: the deck without springs
/* ---- stages 1–2 only: no springs yet. The corner is as tall as its toast and the toast as tall as its words; the
        status icons are drawn at once. From stage 3 app.js places, sizes and draws every toast. ---- */
.toasts { height: auto; }
.deck { position: relative; }
.toast { position: relative; }
.i-done, .toast[data-kind="undo"] .ring { stroke-dashoffset: 0; }
app.js — all of it: fit the frame, colour the browser
// fit the 432 × 768 composition to the screen (?full=1: no frame, the page is the screen)
function fit() { html.style.setProperty('--k', 'full' in html.dataset ? '1' : Math.min(innerWidth / 432, innerHeight / 768).toFixed(4)); }
fit();
addEventListener('resize', fit);
document.querySelector('meta[name="theme-color"]').content = getComputedStyle(html).getPropertyValue('--page').trim();
Stage 1 · one toast, by handOpen ↗
Stage 1 · the same markup, the mono tokensOpen ↗

2 · A toast from a template

The hand-written toast moves into a <template>: markup the browser parses but never shows, ready to be cloned. Every part of the toast is there, empty.

index.html — one toast, as a template
<!-- one toast; app.js fills in where it is from, its picture, its words and its buttons -->
<template id="toast">
  <li class="toast">
    <div class="glass" aria-hidden="true"><i class="fill"></i><i class="timer"></i></div>   <!-- the card's surface -->
    <div class="body">
      <p class="meta"><span class="app"><i class="app-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path/></svg></i><b class="app-name"></b></span><time class="when">now</time></p>
      <div class="row">
        <span class="pic" aria-hidden="true">
          <svg class="status" viewBox="0 0 32 32">
            <circle class="ring-track" cx="16" cy="16" r="13"/><circle class="ring" cx="16" cy="16" r="13" pathLength="1"/>
            <path class="i-done" pathLength="1" d="M10.5 16.5l3.6 3.6 7.4-8"/>
            <path class="i-error" d="M16 9.5v8M16 22v.4"/>
            <path class="i-undo" d="M11 14.5h7.5a4 4 0 0 1 0 8H14M11 14.5l3-3M11 14.5l3 3"/>
          </svg>
          <b class="face"></b>
          <svg class="thing" viewBox="0 0 40 40"><path/></svg>
        </span>
        <div class="text"><p class="t-title"></p><p class="t-desc"></p><p class="t-code"></p></div>
        <button class="t-close" type="button" aria-label="Dismiss"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg></button>
      </div>
      <div class="t-actions"><button class="t-action" type="button"></button><button class="t-action t-alt" type="button"></button></div>
    </div>
  </li>
</template>

toast(spec) clones it, keeps a handle on each part it will write to, and puts it at the front of the deck. The list of toasts in the script is kept newest first, like the deck itself: index 0 is the one in front. For now there is no deck, so a new toast simply takes the place of the one before.

app.js — making a toast
const region = $('.toasts'), deck = $('.deck'), template = $('#toast');
const toasts = [];                                                 // newest first

// toast(spec) — spec: { kind: 'info' | 'done' | 'busy' | 'undo' | 'error', app: { name, icon, tint, dot }, when,
// face: { initials, hue }, thing (an SVG path, 40 × 40), title, desc, code, count: { format(k) }, actions: [{ label, run }],
// duration (s; 0 waits), and for in progress: speed, logs (what it says on the way), status(pct), then (what it becomes) }
function toast(spec) {
  const el = template.content.firstElementChild.cloneNode(true);
  const t = {
    el, body: $('.body', el),
    appPath: $('.app-icon path', el), appName: $('.app-name', el), when: $('.when', el),
    face: $('.face', el), thing: $('.thing path', el), title: $('.t-title', el), desc: $('.t-desc', el), code: $('.t-code', el),
    buttons: $$('.t-action', el), close: $('.t-close', el),
    state: 'in',
  };
  deck.prepend(el);
  set(t, spec);
  toasts.unshift(t);
  while (toasts.filter((o) => o.state === 'in').length > 1) dismiss(toasts.filter((o) => o.state === 'in').pop());   // one at a time (stage 3 keeps five)
  t.close.addEventListener('click', () => dismiss(t));
  t.buttons.forEach((b, i) => b.addEventListener('click', () => t.actions[i]?.run(t)));
  return t;
}

Everything a toast says and does is written by set(t, spec), and that is the trick of this component: the same function makes a toast and turns one toast into another. Reply turns a message into "Reply sent"; Undo turns "Member removed" into "Member restored"; and in stage 4 an upload that reaches the top turns into "12 photos uploaded" through the same call. set() also writes the accessibility: every toast is a status (announced politely, when the reader is free), an error is an alert (announced at once), and a job in progress is marked aria-busy, so a screen reader waits and announces it when it is done. Each toast can take the focus.

app.js — what a toast says, and how one turns into another
// what a toast says and does: used to make one, and to turn it into another (in progress → done, undo → done, …)
function set(t, spec) {
  if (spec.kind === 'busy') spec = spec.then;                      // until stage 4 fills it, a job in progress is done at once
  const { kind, app, when = 'now', face, thing, title, desc = '', code = '', count, actions = [], duration = 5 } = spec;
  Object.assign(t, { kind, actions, duration, left: duration, count, then: spec.then, logs: spec.logs, status: spec.status, speed: spec.speed || 0.45 });
  t.el.dataset.kind = kind;
  t.el.setAttribute('role', kind === 'error' ? 'alert' : 'status');
  t.el.setAttribute('aria-busy', String(kind === 'busy'));
  t.el.tabIndex = 0;
  t.el.toggleAttribute('data-app', !!app);                        // where it is from: an app icon and name, or the small print
  if (app) {
    t.appName.textContent = app.name; t.when.textContent = when;
    if (app.icon) t.appPath.setAttribute('d', app.icon);
    t.el.style.setProperty('--icon', app.tint || '');
    t.el.style.setProperty('--dot', app.dot || '');
  }
  if (face) t.el.dataset.pic = 'face'; else if (thing) t.el.dataset.pic = 'thing'; else delete t.el.dataset.pic;   // a face, a thing, or the status icon
  if (face) { t.face.textContent = face.initials; t.el.style.setProperty('--hue', face.hue); }
  if (thing) t.thing.setAttribute('d', thing);
  t.title.textContent = title;
  t.desc.textContent = count ? count.format(1) : desc;             // a count shows its figure (stage 4 rolls up to it)
  t.code.textContent = code;
  t.buttons.forEach((b, i) => { b.textContent = actions[i]?.label || ''; });
  t.el.toggleAttribute('data-actions', actions.length > 0);
}

Until stage 4 fills them, a job in progress is done at once: the first line of set() skips straight to what it becomes. A payment's amount shows its figure; stage 4 rolls up to it. Closing is instant for now, and if the toast you close had the focus, the focus moves to the next toast or to the corner itself, never to the top of the page.

app.js — closing a toast, for now
// a toast leaves: for now it is simply gone (stage 3 sends it back the way it came)
function dismiss(t) {
  if (!t || t.state !== 'in') return;
  t.state = 'out';
  if (document.activeElement === t.el || t.el.contains(document.activeElement)) (toasts.find((o) => o.state === 'in')?.el || region).focus();   // the focus stays in the deck
  t.el.remove();
  toasts.splice(toasts.indexOf(t), 1);
}

What the page's buttons raise is one table per look: a label, the small print, an icon, and a function that calls toast(). Here is frost's: a message from a colleague with Reply, a payment that counts up, an upload, a calendar invite with Accept and Decline.

app.js — what frost's four buttons raise
// each look's four buttons: [label, small print, icon, what it raises]
const SCRIPTS = {
  frost: {
    a: ['Message', 'from Aysel', ICON.chat, () => toast({ kind: 'info', app: APP.messages, face: { initials: 'AK', hue: 300 }, title: 'Aysel Karimova', desc: 'Can you look at the deck before 4?', duration: 6,
      actions: [{ label: 'Reply', run: (t) => set(t, { kind: 'done', app: APP.messages, title: 'Reply sent', desc: 'On it — give me 15 minutes.', duration: 3 }) }, { label: 'Mark as read', run: done }] })],
    b: ['Payment', 'Wallet', ICON.card, () => toast({ kind: 'done', app: APP.wallet, title: 'Payment received', count: { format: money('+$', 1240, ' from Northpine') }, duration: 5 })],
    c: ['Upload', '12 photos', ICON.photo, () => toast({ kind: 'busy', app: APP.photos, title: 'Uploading 12 photos', desc: '0 of 12', duration: 0, speed: 0.5, status: (p) => `${Math.round(p * 0.12)} of 12`,
      then: { kind: 'done', app: APP.photos, title: '12 photos uploaded', desc: 'Lisbon, October · shared album', actions: [{ label: 'Open album', run: done }] } })],
    d: ['Invite', 'Calendar', ICON.cal, () => toast({ kind: 'info', app: APP.calendar, thing: THING.calendar, title: 'Design review', desc: 'Today, 16:00 – 16:30 · Studio', duration: 8,
      actions: [{ label: 'Accept', run: (t) => set(t, { kind: 'done', app: APP.calendar, title: 'You’re going', desc: 'Design review · 16:00', duration: 3 }) }, { label: 'Decline', run: done }] })],
  },
app.js — the buttons, named and drawn for the look
// the page's buttons: named and drawn for the look; each raises its toast
$$('[data-toast]').forEach((b) => {
  const [label, sub, icon, raise] = SCRIPTS[b.dataset.toast];
  $('b', b).textContent = label; $('span', b).textContent = sub; $('path', b).setAttribute('d', icon);
  b.addEventListener('click', raise);
});
Stage 2 · press the buttons, then Reply or AcceptOpen ↗

3 · The deck, on springs

Now things move, and all of it on one small thing: a spring. A value has a target, a velocity, a stiffness and a damping, and it is stepped in fixed slices of 1/240 s, so it moves the same at 60 and 120 frames a second. Changing the target mid-way never jumps: the motion just bends towards the new target.

spring.js — all of it
// A spring, written from scratch: a mass on a spring with a damper.
//   acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
  constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
    this.value = value;
    this.target = value;
    this.velocity = 0;
    this.stiffness = stiffness;
    this.damping = damping;
  }

  set(options) { Object.assign(this, options); return this; }

  step(dt) {
    const slice = 1 / 240;
    for (let t = dt; t > 0; t -= slice) {
      const h = Math.min(slice, t);
      const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
      this.velocity += acceleration * h;
      this.value += this.velocity * h;
    }
    return this.value;
  }

  resting(epsilon = 0.01) {
    return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
  }
}

With prefers-reduced-motion every spring is critically damped: it still arrives, it just never overshoots. That one line is most of this component's reduced motion.

app.js — reduced motion is a damping
// With reduced motion every spring is critically damped: things still arrive, they just don't bounce.
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
const critical = (stiffness) => ({ stiffness, damping: 2 * Math.sqrt(stiffness) });

Each look moves its own way, and all of it is in one table. from is how far beyond its edge a toast starts, grow the scale it starts at, move and fade its springs, and tilt how much it leans while you swipe it (stage 5). Frost drops in from 46 px away on a spring that may bounce, like a phone's banner; mono slides 14 px on a stiff, critically damped spring, exact and quick; ivory barely moves (12 px, no scale) and fades in slowly.

app.js — how each look moves
// how each look moves. from: how far beyond its edge a toast starts (px); grow: the scale it starts at; move, fade:
// its springs; tilt: degrees per px of a swipe. Frost drops in softly, Mono slides in exact, Ivory fades in slowly.
const FEEL = {
  frost: { from: 46, grow: 0.94, move: spring(210, 20), fade: critical(140), tilt: 0.03 },
  mono: { from: 14, grow: 0.98, move: critical(520), fade: critical(420), tilt: 0.02 },
  ivory: { from: 12, grow: 1, move: critical(110), fade: critical(60), tilt: 0.012 },
}[look];

The script reads the stylesheet's --anchor once and turns it into one sign. Every offset in the deck is measured away from its edge, as a positive number; away turns it into screen pixels, down for a deck that hangs from the top and up for one that stands on the bottom. Nothing else in the script knows which corner it is in.

app.js — the top or the bottom, as one sign
const anchor = getComputedStyle(region).getPropertyValue('--anchor').trim();   // the deck hangs from the top, or stands on the bottom
const away = anchor === 'top' ? 1 : -1;                            // the direction a y offset moves on screen

A toast gets its springs when it is made: its offset starts from beyond the edge, its scale at grow, its opacity at 0, and then the targets are set. Five are kept; a sixth pushes the oldest out.

app.js — a toast gets its springs
function toast(spec) {
  const el = template.content.firstElementChild.cloneNode(true);
  const t = {
    el, body: $('.body', el),
    appPath: $('.app-icon path', el), appName: $('.app-name', el), when: $('.when', el),
    face: $('.face', el), thing: $('.thing path', el), title: $('.t-title', el), desc: $('.t-desc', el), code: $('.t-code', el),
    buttons: $$('.t-action', el), close: $('.t-close', el),
    state: 'in', height: 64,
    y: new Spring(-FEEL.from, FEEL.move), sc: new Spring(FEEL.grow, spring(260, 24)), op: new Spring(0, FEEL.fade),
    h: new Spring(64, spring(300, 26)), show: new Spring(1, spring(260, 26)),
  };
  deck.prepend(el);
  set(t, spec);
  t.h.value = t.height;
  t.op.target = 1;
  toasts.unshift(t);
  while (toasts.filter((o) => o.state === 'in').length > 5) dismiss(toasts.filter((o) => o.state === 'in').pop());
  layout();
  t.close.addEventListener('click', () => dismiss(t));
  t.buttons.forEach((b, i) => b.addEventListener('click', () => t.actions[i]?.run(t)));
  wake();
  return t;
}

The deck model is one function. Closed, the toast in front is whole, and the two before it peek out behind it by 12 px each, 5% smaller each time, as tall as the front one and showing only their edge (their words fade out); any older ones are hidden behind. Open, the deck becomes a list: each toast at its own height, 10 px apart, the newest nearest the edge. The corner's height follows (--deck-h), so the area you can point at is always the area you see. In a closed deck only the front toast's buttons are in the tab order.

app.js — the deck, closed and open
const PEEK = 12, GAP = 10, SHOWN = 3;                              // how far an older toast peeks out; the gap when open
let open = false;
function layout() {
  const live = toasts.filter((t) => t.state === 'in');
  let top = 0;
  live.forEach((t, i) => {
    if (open) {                                                    // the list: each at its own height, the newest nearest the edge
      t.y.target = top; t.sc.target = 1; t.h.target = t.height; t.show.target = 1;
      top += t.height + GAP;
    } else {                                                       // the deck: the older ones behind, smaller, only their edge shows
      t.y.target = Math.min(i, SHOWN - 1) * PEEK; t.sc.target = 1 - Math.min(i, SHOWN) * 0.05;
      t.h.target = live[0].height; t.show.target = i === 0 ? 1 : 0;
    }
    t.op.target = i < (open ? 5 : SHOWN) ? 1 : 0;
    t.el.style.zIndex = String(100 - i);
    t.el.querySelectorAll('button').forEach((b) => { b.tabIndex = open || i === 0 ? 0 : -1; });
  });
  const h = !live.length ? 0 : open ? top - GAP : live[0].height + Math.min(live.length - 1, SHOWN - 1) * PEEK;
  region.style.setProperty('--deck-h', px(h));
}
function setOpen(next) { if (open !== next) { open = next; layout(); wake(); } }

Pointing at the deck opens it and leaving closes it a beat later (0.2 s, so a pointer that slips out for a moment doesn't snap it shut). Focus opens it too, so a keyboard user gets the list; on a touch screen a tap opens it, and a tap anywhere else closes it.

app.js — what opens and closes the deck
// pointing at the deck opens it, leaving closes it (a beat later); a tap opens it on a touch screen; so does focus
region.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') setOpen(true); });
region.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') later(0.2, () => { if (!region.matches(':hover') && !region.contains(document.activeElement)) setOpen(false); }); });
region.addEventListener('focusin', () => setOpen(true));
region.addEventListener('focusout', () => setTimeout(() => { if (!region.contains(document.activeElement) && !region.matches(':hover')) setOpen(false); }, 0));
addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse' && !region.contains(e.target)) setOpen(false); });

A toast that leaves goes back the way it came and fades; while it goes it loses its role and becomes inert, so it is neither announced nor reachable.

app.js — leaving the way it came
// a toast leaves: it goes back the way it came, and fades
function dismiss(t) {
  if (!t || t.state !== 'in') return;
  t.state = 'out';
  t.el.removeAttribute('role');
  t.el.inert = true;
  t.y.target = -FEEL.from * 0.7; t.sc.target = FEEL.grow;
  t.op.target = 0;
  if (document.activeElement === t.el || t.el.contains(document.activeElement)) (toasts.find((o) => o.state === 'in')?.el || region).focus();   // the focus stays in the deck
  layout();
  wake();
}

Every frame, render() writes the springs to the page: the height, one transform (the offset times away, and the scale) and the opacity. Styles are written only when they change, and a toast is promoted to its own layer (will-change) only while it moves.

app.js — drawing the deck
// ---- rendering ----
function render() {
  for (const t of toasts) {
    const moving = ![t.y, t.sc, t.op, t.h].every((s) => s.resting(0.01));
    promote(t.el, moving);
    put(t.el, 'height', px(Math.max(8, t.h.value)));
    put(t.el, 'transform', `translateY(${px(away * t.y.value)}) scale(${t.sc.value.toFixed(4)})`);
    put(t.el, 'opacity', clamp(t.op.value, 0, 1).toFixed(3));
    const words = clamp(t.show.value, 0, 1);                       // the older toasts in the deck show only their edge
    put(t.body, 'opacity', words.toFixed(3));
    t.body.style.pointerEvents = words > 0.5 ? '' : 'none';
  }
}

One loop steps every spring, runs what later() queued, removes the toasts that have faded out, and draws. It sleeps as soon as everything rests.

app.js — the loop
// ---- the loop: it runs only while something moves (stage 6 gives the page a light that drifts) ----
const springs = () => toasts.flatMap((t) => [t.y, t.sc, t.op, t.h, t.show]);
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  clock += dt;
  for (let i = queue.length - 1; i >= 0; i--) if (queue[i].at <= clock) queue.splice(i, 1)[0].fn();
  const all = springs();
  all.forEach((s) => s.step(dt));
  for (let i = toasts.length - 1; i >= 0; i--) {                   // gone: out, faded, at rest
    const t = toasts[i];
    if (t.state === 'out' && t.op.value < 0.02 && t.op.resting(0.02)) { t.el.remove(); toasts.splice(i, 1); }
  }
  render();
  const still = all.every((s) => s.resting(0.002)) && !queue.length;
  if (!still) requestAnimationFrame(frame); else running = false;
}
Stage 3 · raise all four, then point at the deckOpen ↗
Stage 3 · the mono deck, standing on the bottomOpen ↗

4 · The kinds: done, in progress, undo, error

Each kind gets its behaviour, and all of it is springs kicked by set(). Done draws its tick (a stroke whose dash offset goes from 1 to 0) and, when a toast turns into done, the card gets a small push of velocity on its scale: a bump that says "this one changed". An error gets a sideways kick on a stiff, loose spring, so it shakes. In progress starts from 0. A count rolls up from nothing.

app.js — set() kicks the springs of the kinds
if (count) { t.counter.value = 0; t.counter.target = 1; t.counted = -1; }
t.height = t.body.offsetHeight;                                  // the card reshapes to fit the new words
if (kind === 'done') { t.draw.value = 0; t.draw.target = 1; if (was && !calm) t.sc.velocity += 0.9; }
else t.draw.target = 0;
if (kind === 'error' && !calm) t.shake.velocity += 320;
if (kind === 'busy') { t.progress = 0; t.fillS.value = 0; t.log = -1; t.pct = -1; }

A job in progress moves itself, quick at first and easing near the top (each frame adds less as it fills). Its words say how far it is: a percent, a status function ("4 of 12", "Wrapping · 37%"), or a log that steps through what it is doing. At the top it calls set(t, t.then), and the same toast becomes what comes next; because aria-busy goes back to false there, that is when a screen reader announces it. The count lands on the exact figure when its spring comes to rest, so the last digits never flicker.

app.js — in progress, and a count
// in progress fills; its words count (or a log says what it is doing); at the top it turns into what comes next
function progress(t, dt) {
  if (t.kind !== 'busy' || t.state !== 'in') return;
  t.progress = Math.min(1, t.progress + dt * t.speed * (1.15 - t.progress * 0.6));   // quick at first, then easing
  t.fillS.target = t.progress;
  if (t.logs) {
    const i = Math.min(t.logs.length - 1, Math.floor(t.progress * t.logs.length));
    if (t.log !== i) { t.log = i; t.code.textContent = t.logs[i]; }
  } else {
    const pct = Math.floor(t.progress * 100);
    if (t.pct !== pct) { t.pct = pct; t.desc.textContent = t.status ? t.status(pct) : `${pct}%`; }
  }
  if (t.progress >= 1) set(t, t.then);
}
// a count: the amount rolls up from nothing as the toast arrives, and lands on the exact figure
function counting(t) {
  if (!t.count || t.counted === t.counter.value) return;
  t.counted = t.counter.value;
  t.desc.textContent = t.count.format(t.counter.resting(0.002) ? 1 : t.counter.value);
}
app.js — drawing the kinds
// the kinds: the fill and the ring (in progress), the ring (undo: stage 5 makes it a countdown), the tick
put(t.fill, 'width', `${(clamp(t.fillS.value, 0, 1) * 100).toFixed(2)}%`);
put(t.fill, 'opacity', t.kind === 'busy' ? '1' : '0');
const ring = t.kind === 'busy' ? t.fillS.value : t.kind === 'undo' ? t.left / t.duration : 0;
put(t.ring, 'strokeDashoffset', (1 - clamp(ring, 0, 1)).toFixed(4));
put(t.ring, 'opacity', t.kind === 'busy' || t.kind === 'undo' ? '1' : '0');
put(t.tick, 'strokeDashoffset', (1 - clamp(t.draw.value, 0, 1)).toFixed(4));

Here is mono's deploy: a busy toast with a log, and what it becomes. Retry on an error is the same idea twice: the error turns into a job in progress, which turns into done.

app.js — a deploy, and what it becomes
a: ['Deploy', 'main → production', ICON.rocket, () => toast({ kind: 'busy', app: repo('calmdeck-web · main', 'amber'), title: 'Deploying to production', desc: 'Commit 3f2a9c1 · “Fix the pricing grid”', duration: 0, speed: 0.38,
  logs: ['Cloning repository…', 'Installing dependencies…', 'Building…', 'Uploading build output…', 'Assigning domain…'],
  then: { kind: 'done', app: repo('calmdeck-web · main', 'green'), title: 'Ready in 41s', desc: 'Your changes are live.', code: 'calmdeck.app', actions: [{ label: 'Visit', run: done }, { label: 'Logs', run: done }] } })],
app.js — an error whose Retry is a job in progress
d: ['Break build', 'error', ICON.bug, () => toast({ kind: 'error', app: repo('calmdeck-web · preview', 'red'), title: 'Build failed', code: 'Error: Cannot find module ./config', duration: 7,
  actions: [{ label: 'Retry', run: (t) => set(t, { kind: 'busy', app: repo('calmdeck-web · preview', 'amber'), title: 'Rebuilding', duration: 0, speed: 0.7, logs: ['Restoring cache…', 'Building…', 'Uploading…'],
    then: { kind: 'done', app: repo('calmdeck-web · preview', 'green'), title: 'Ready in 18s', code: 'pr-128.calmdeck.app', actions: [{ label: 'Visit', run: done }] } }) },
  { label: 'View logs', run: done }] })],
Stage 4 · Deploy, then Break build and RetryOpen ↗
Stage 4 · Ship order, Decline card, Try againOpen ↗

5 · Swiping, the timers, the keyboard

A toast follows the pointer sideways. Pressing on a button doesn't start a drag, and a move under 4 px is still a tap (on a touch screen, a tap opens the deck). The distance is divided by the frame's scale, so the thresholds mean the same at any size. The velocity comes from the last six pointer samples: the distance from the oldest of them to where you let go, over the time between. Let go past 90 px, or faster than 0.5 px per millisecond, and the toast is thrown; otherwise it springs back, starting with the speed your hand gave it.

app.js — the swipe
// swipe: the toast follows the finger; thrown far or fast enough it flies off, otherwise it springs back
function grab(t) {
  let start = null, last = null, moved = false;
  t.el.addEventListener('pointerdown', (e) => {
    if (e.button !== 0 || e.target.closest('button') || t.state !== 'in') return;
    e.preventDefault();                                  // a drag, not a text selection
    start = { x: e.clientX }; last = [{ x: e.clientX, at: e.timeStamp }]; moved = false;
    t.el.setPointerCapture(e.pointerId);
  });
  t.el.addEventListener('pointermove', (e) => {
    if (!start) return;
    const k = region.getBoundingClientRect().width / region.offsetWidth, dx = (e.clientX - start.x) / k;
    if (!moved && Math.abs(dx) < 4) return;
    moved = true;
    t.x.value = t.x.target = dx; t.x.velocity = 0;
    last.push({ x: e.clientX, at: e.timeStamp }); if (last.length > 6) last.shift();
    wake();
  });
  const end = (e) => {
    if (!start) return;
    const k = region.getBoundingClientRect().width / region.offsetWidth, dx = (e.clientX - start.x) / k;
    const a = last[0], v = (e.clientX - a.x) / k / Math.max(1, e.timeStamp - a.at);   // px per ms
    start = null;
    if (!moved) { if (e.pointerType !== 'mouse' && !open) setOpen(true); return; }   // a tap: open the deck
    if (Math.abs(dx) > 90 || Math.abs(v) > 0.5) dismiss(t, Math.sign(dx || v));
    else { t.x.target = 0; t.x.velocity = v * 1000; }
    wake();
  };
  t.el.addEventListener('pointerup', end);
  t.el.addEventListener('pointercancel', end);
}

A thrown toast flies off the side it was thrown to, by its own width plus a margin, on a softer spring; one that is let go (×, Escape, a timer) goes back the way it came.

app.js — thrown, or let go
// a toast leaves: thrown (it flies off the way it was thrown) or let go (it goes back the way it came, and fades)
function dismiss(t, thrown = 0) {
  if (!t || t.state !== 'in') return;
  t.state = 'out';
  t.el.removeAttribute('role');
  t.el.inert = true;
  if (thrown) { t.x.target = thrown * (t.el.offsetWidth + 80); t.x.set(spring(140, 18)); }
  else { t.y.target = -FEEL.from * 0.7; t.sc.target = FEEL.grow; }

While it moves sideways it leans (tilt degrees per pixel, from the look's FEEL) and fades with the distance.

app.js — leaning and fading as it goes
const x = t.x.value + t.shake.value;
const moving = ![t.y, t.sc, t.op, t.h, t.x, t.shake].every((s) => s.resting(0.01));
promote(t.el, moving);
put(t.el, 'height', px(Math.max(8, t.h.value)));
put(t.el, 'transform', `translate(${px(x)}, ${px(away * t.y.value)}) rotate(${(x * FEEL.tilt).toFixed(3)}deg) scale(${t.sc.value.toFixed(4)})`);
put(t.el, 'opacity', clamp(t.op.value * (1 - Math.abs(t.x.value) / 600), 0, 1).toFixed(3));

Now every toast has its time. A timer that runs out while you are reading is the most common way toasts are rude, so every countdown waits while the deck is open (pointed at, tapped, or holding the focus) and while the page is hidden. A job in progress has no timer: it ends when it is done.

app.js — timers that pause while you read
// every countdown waits while you read the deck (it is open, or something in it has the focus) or the page is hidden
const paused = () => open || document.hidden;
function count(dt) {
  if (paused()) return;
  for (const t of toasts) if (t.state === 'in' && t.duration && t.kind !== 'busy') {
    t.left -= dt;
    if (t.left <= 0) dismiss(t);
  }
}

The undo ring is the time left (the ring that stage 4 drew, now counting down); the other timed kinds show a thin line along the bottom of the card that shrinks.

app.js — the timer line
const timed = t.kind !== 'busy' && t.kind !== 'undo' && t.duration > 0;
put(t.timer, 'opacity', timed ? '1' : '0');
put(t.timer, 'transform', `scaleX(${clamp(t.left / (t.duration || 1), 0, 1).toFixed(4)})`);

And the keyboard: Alt+T, the shortcut in the corner's label, moves the focus to the newest toast (which opens the deck); Tab moves through the toasts and their buttons; Escape closes the one in focus.

app.js — Alt+T
// Alt+T moves the focus into the toasts (and so opens the deck); Escape there closes the one in focus
addEventListener('keydown', (e) => {
  if (e.altKey && e.code === 'KeyT') { e.preventDefault(); (toasts.find((t) => t.state === 'in')?.el || region).focus(); }
});
app.js — Escape, in toast()
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); dismiss(t); } });
Stage 5 · throw one off the edge; press Alt+T, then EscapeOpen ↗

6 · The post

The last stage makes it the page that was posted. Each look is its own post in the series, with its own number, name and line in the title.

index.html — each look, its own post
// each look is its own post in the series: its number, its name and its line in the title
const [num, name, hint] = {
  frost: ['14', 'Frost', 'Drop in. Stack up. Swipe away.'],
  mono: ['15', 'Mono', 'Deploy. Copy. Undo. Retry.'],
  ivory: ['16', 'Ivory', 'Paid. Shipped. Quietly told.'],
}[html.dataset.look];
document.addEventListener('DOMContentLoaded', () => {
  document.querySelector('.series b').textContent = num;
  document.querySelector('.title h1').firstChild.textContent = `${name} `;
  document.querySelector('.hint').textContent = hint;
  document.title = `${name} toasts — UI Components ${num}`;
});

The page gets a light that drifts behind everything. It reads time from a studio clock that a capture tool can pause or drive, and it is the one thing that keeps the loop running every frame; with reduced motion it stands still and the loop sleeps again whenever nothing moves. A footer with the credits and a touch circle (for the video) arrive too, and a small console API for scripts.

app.js — the loop, with a light that drifts
// ---- the loop: with ambient life it runs every frame; with reduced motion only while something moves ----
const glows = $$('.amb .glow');
const springs = () => toasts.flatMap((t) => [t.y, t.sc, t.op, t.h, t.x, t.shake, t.show, t.draw, t.fillS, t.counter]);
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  clock += dt;
  for (let i = queue.length - 1; i >= 0; i--) if (queue[i].at <= clock) queue.splice(i, 1)[0].fn();
  for (const t of toasts) { progress(t, dt); counting(t); }
  count(dt);
  const all = springs();
  all.forEach((s) => s.step(dt));
  for (let i = toasts.length - 1; i >= 0; i--) {                   // gone: out, faded, at rest
    const t = toasts[i];
    if (t.state === 'out' && t.op.value < 0.02 && t.op.resting(0.02)) { t.el.remove(); toasts.splice(i, 1); }
  }
  const t = calm ? 0 : studio.now() / 1000;
  glows.forEach((g, i) => put(g, 'transform', `translate(${px(Math.sin(t * (0.13 + i * 0.05) + i * 2) * 26)}, ${px(Math.cos(t * (0.11 + i * 0.04) + i) * 18)})`));
  render();
  const still = all.every((s) => s.resting(0.002)) && !queue.length && !toasts.some((o) => o.state === 'in' && (o.duration || o.kind === 'busy'));
  if (!calm || !still) requestAnimationFrame(frame); else running = false;
}
wake();
app.js — for scripts and the console
// for scripts and the tutorial's console: raise a toast by its button (a, b, c, d), open or close the deck, read the state
window.__toasts = {
  raise: (name) => SCRIPTS[name][3](),
  open: (on = true) => setOpen(on),
  state: () => ({ open, toasts: toasts.filter((t) => t.state === 'in').map((t) => ({ kind: t.kind, title: t.title.textContent, left: +t.left.toFixed(2) })) }),
};
Stage 6 · the finished stackOpen ↗

Three looks, three motions

The looks share the markup, the script and the three token layers; each is a block of semantic tokens, one row of FEEL and its own four buttons. Here are mono and ivory.

style.css — mono's semantic tokens
/* B · MONO: a developer tool at night. Near-black, 1px hairlines, a soft light from above, monospace for what a machine
   says; quick and exact, nothing bounces */
[data-look="mono"] {
  --title-face: var(--sans); --title-w: 600; --title-size: 44px; --em-face: var(--mono); --em-style: normal; --em-w: 400; --em-size: .82em; --em: var(--mo-300);
  --toast-face: var(--sans); --toast-w: 500; --title-px: 14px; --desc-px: 13px; --meta-face: var(--mono); --meta-case: none; --meta-track: 0;
  --page: var(--mo-950);
  --field-bg: radial-gradient(60% 34% at 50% 0%, rgb(255 255 255 / .09), transparent 72%), radial-gradient(rgb(255 255 255 / .07) 1px, transparent 1px) 0 0 / 22px 22px;
  --on-field: var(--mo-100); --on-field-muted: var(--mo-400); --chip: var(--mo-100); --on-chip: var(--mo-950);
  --avatar-ring: var(--mo-400); --lockup: none; --light: rgb(255 255 255 / .05);
  --surface: linear-gradient(180deg, var(--mo-850), var(--mo-900)); --surface-filter: none;
  --surface-rim: inset 0 0 0 1px var(--mo-700), inset 0 1px 0 rgb(255 255 255 / .06);
  --surface-drop: 0 16px 36px -14px rgb(0 0 0 / .9); --toast-r: 10px;
  --on-toast: var(--mo-100); --on-toast-muted: var(--mo-300); --ok: var(--mo-green); --bad: var(--mo-red); --busy: var(--mo-amber);
  --track: var(--mo-700); --fill: var(--mo-100); --timer: var(--mo-600);
  --code: var(--mo-800); --code-edge: var(--mo-700); --on-code: var(--mo-100);
  --btn: var(--mo-100); --on-btn: var(--mo-950); --btn-alt: transparent; --on-btn-alt: var(--mo-100); --btn-alt-edge: var(--mo-600);
  --btn-r: 6px; --btn-case: none; --btn-track: 0; --thing: var(--mo-100);
  --act: var(--mo-900); --act-filter: none; --act-rim: inset 0 0 0 1px var(--mo-700), inset 0 1px 0 rgb(255 255 255 / .05);
  --act-press: translateY(1px); --act-edge: var(--mo-700); --on-act: var(--mo-100); --on-act-muted: var(--mo-400); --act-face: var(--sans); --act-w: 500;
  --pill: var(--mo-900); --pill-edge: var(--mo-700); --on-pill: var(--mo-100); --on-pill-muted: var(--mo-400); --pill-icon: var(--mo-100); --on-pill-icon: var(--mo-950);
}
style.css — ivory's semantic tokens
/* C · IVORY: an editorial page. Ivory paper, ink, a serif for what matters, letter-spaced capitals for the small print
   and a gold hairline framing each card; it arrives and leaves slowly, without a sound */
[data-look="ivory"] {
  --title-face: var(--serif); --title-w: 400; --title-size: 54px; --em-face: var(--serif); --em-style: italic; --em-w: 400; --em-size: 1em; --em: var(--iv-gold);
  --toast-face: var(--serif); --toast-w: 400; --title-px: 21px; --desc-px: 13px; --meta-face: var(--sans); --meta-case: uppercase; --meta-track: .22em;
  --page: var(--iv-100);
  --field-bg: radial-gradient(70% 50% at 50% 40%, var(--iv-50) 0%, var(--iv-100) 70%, var(--iv-200) 100%);
  --on-field: var(--iv-ink); --on-field-muted: var(--iv-ink-2); --chip: var(--iv-ink); --on-chip: var(--iv-50);
  --avatar-ring: var(--iv-ink); --lockup: brightness(0); --light: rgb(255 255 255 / .5);
  --surface: var(--iv-50); --surface-filter: none;
  --surface-rim: inset 0 0 0 1px rgb(20 20 20 / .1), inset 0 0 0 5px var(--iv-50), inset 0 0 0 6px rgb(168 133 74 / .42);
  --surface-drop: 0 26px 44px -26px rgb(60 45 20 / .38); --toast-r: 2px;
  --on-toast: var(--iv-ink); --on-toast-muted: var(--iv-ink-2); --ok: var(--iv-gold); --bad: var(--iv-red); --busy: var(--iv-gold);
  --track: rgb(168 133 74 / .18); --fill: var(--iv-gold); --timer: rgb(168 133 74 / .55);
  --code: transparent; --code-edge: transparent; --on-code: var(--iv-ink);
  --btn: transparent; --on-btn: var(--iv-ink); --btn-alt: transparent; --on-btn-alt: var(--iv-ink-2); --btn-alt-edge: transparent;
  --btn-r: 0; --btn-case: uppercase; --btn-track: .16em; --thing: var(--iv-gold);
  --act: var(--iv-50); --act-filter: none; --act-rim: inset 0 0 0 1px rgb(20 20 20 / .1), 0 18px 30px -24px rgb(60 45 20 / .35);
  --act-press: translateY(1px); --act-edge: rgb(168 133 74 / .4); --on-act: var(--iv-ink); --on-act-muted: var(--iv-ink-3); --act-face: var(--serif); --act-w: 400;
  --pill: rgb(251 248 242 / .85); --pill-edge: rgb(20 20 20 / .1); --on-pill: var(--iv-ink); --on-pill-muted: var(--iv-ink-3); --pill-icon: var(--iv-ink); --on-pill-icon: var(--iv-50);
}
Look · frost, drops in from the topOpen ↗
Look · mono, quick and exactOpen ↗
Look · ivory, slow and quietOpen ↗

Make it yours

  • Looks are tokens and one row of FEEL: pick one with ?look=frost, mono or ivory (or a, b, c). ?full=1 shows the real page at the size of your window, with the toasts in its corner.
  • Your own toasts are one call: toast({ kind, app, title, desc, actions, duration }). Give a job in progress a then and it turns into it when it is done; a real app would set its progress from the upload or the deploy instead of the easing curve.
  • The corner is the stylesheet's: move the corner and change --anchor, and the script follows.
  • Accessibility is already there: a labelled region that Alt+T moves into, a list of toasts that are announced (errors at once, the rest politely, a job in progress when it is done), each focusable, closed with Escape or a real × button, real buttons for actions, hidden toasts out of the tab order and inert while they leave, the focus kept in the deck, timers that wait while you read, and prefers-reduced-motion.
  • Nothing goes anywhere. Calmdeck is made up, and every action only changes the toast.
The one idea worth keeping

A toast is not a thing that appears and disappears; it is a slot whose content changes. One set() writes what a toast says, and every change of state (sent, uploaded, restored, failed, retried) calls it again on the same card, so the card reshapes, the deck follows and a screen reader hears the news, instead of one toast vanishing and another arriving.