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.
<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>
<!-- 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.
<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.
: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.
/* 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.
.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.
(() => {
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.
/* ---- 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; }
/* ---- 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.
/* 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-…)).
/* ---- 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.
/* ---- 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; }
// 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();
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.
<!-- 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.
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.
// 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.
// 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.
// 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 }] })],
},
// 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);
});
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.
// 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.
// 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.
// 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.
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.
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.
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.
// 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.
// 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.
// ---- 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.
// ---- 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;
}
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.
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.
// 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);
}
// 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.
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 }] } })],
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 }] })],
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.
// 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.
// 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.
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.
// 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.
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.
// 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(); }
});
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); dismiss(t); } });
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.
// 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.
// ---- 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();
// 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) })) }),
};
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.
/* 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);
}
/* 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);
}
Make it yours
- Looks are tokens and one row of
FEEL: pick one with?look=frost,monoorivory(ora,b,c).?full=1shows 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 athenand 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.
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.