css-is-awesome 1.14.2 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
1
+ ---
2
+ name: toast
3
+ description: Transient notifications — a fixed, stacked region of auto-dismissing status messages with pause-on-hover, a close button and an optional action, announced through the right live-region role for their severity.
4
+ category: feedback
5
+ complexity: medium
6
+ cia-version: ">=1.0.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You need to tell the user something happened — "Saved", "Upload failed", "Message sent, Undo?" — without interrupting what they're doing. A toast is **non-blocking feedback that goes away on its own**. If the message needs a decision before the user can continue, bail and use the [`confirm-dialog`](./confirm-dialog.md) recipe; if it's a persistent condition (a form error, a degraded connection), use `cia.alert` inline where the problem is, not a toast that disappears.
12
+
13
+ Build it as **one region, many toasts**: a single fixed-position `<section>` that stacks every notification, rendered once at the app root. Each toast is an ordinary element inside it — no portals, no third-party library. The browser's live-region semantics do the announcing; ~40 lines of JS own the timers.
14
+
15
+ ## Structure (raw HTML)
16
+
17
+ ```html
18
+ <section data-cia-recipe="toast" aria-label="Notifications" data-slot="region">
19
+ <div role="status" data-status="success" data-slot="toast">
20
+ <span data-slot="icon" aria-hidden="true">✓</span>
21
+ <div data-slot="body">
22
+ <strong data-slot="title">Saved</strong>
23
+ <span data-slot="message">Your changes are live.</span>
24
+ </div>
25
+ <button type="button" data-slot="action">Undo</button>
26
+ <button type="button" data-slot="close" aria-label="Dismiss notification">×</button>
27
+ <span data-slot="progress" aria-hidden="true"></span>
28
+ </div>
29
+
30
+ <div role="alert" data-status="error" data-slot="toast">
31
+ <span data-slot="icon" aria-hidden="true">!</span>
32
+ <div data-slot="body">
33
+ <strong data-slot="title">Upload failed</strong>
34
+ <span data-slot="message">The file exceeded 10 MB.</span>
35
+ </div>
36
+ <button type="button" data-slot="close" aria-label="Dismiss notification">×</button>
37
+ <span data-slot="progress" aria-hidden="true"></span>
38
+ </div>
39
+ </section>
40
+ ```
41
+
42
+ Notes on the markup:
43
+
44
+ - **The region exists before the first toast.** Screen readers only reliably announce live-region changes when the region was already in the accessibility tree; a container that mounts *with* its first toast is often silent. Render `[data-slot="region"]` empty at app start.
45
+ - **`role` follows severity, not appearance.** `role="status"` (implicitly `aria-live="polite"`) for `info` and `success`; `role="alert"` (`aria-live="assertive"`) for `warning` and `error`. Put the role on the toast itself, not on the region — each toast is then announced once, when it appears, instead of the whole stack re-reading on every change.
46
+ - `data-status` carries the visual variant. It's a `data-*` attribute rather than a class because it's cosmetic; the semantic state is already in `role`.
47
+ - `[data-slot="progress"]` is the countdown bar — purely decorative, `aria-hidden`. The timer lives in JS; the bar just mirrors it.
48
+ - The close button is a real `<button>` with an accessible name. The `×` glyph alone is not a name.
49
+
50
+ ## Styling (cia mixins)
51
+
52
+ ```scss
53
+ // Toasts.module.scss — component stylesheet, so import the zero-emit barrel.
54
+ @use 'css-is-awesome/api' as cia;
55
+
56
+ .my-toasts {
57
+ position: fixed;
58
+ inset-block-end: cia.space(4);
59
+ inset-inline-end: cia.space(4);
60
+ z-index: cia.z(toast);
61
+ inline-size: min(24rem, calc(100vw - #{cia.space(8)}));
62
+ pointer-events: none; // the region itself never blocks clicks
63
+ @include cia.stack($gap: 2);
64
+
65
+ // Mobile: one full-width column pinned to the bottom edge.
66
+ @include cia.mobile-only {
67
+ inset-inline: cia.space(2);
68
+ inset-block-end: cia.space(2);
69
+ inline-size: auto;
70
+ }
71
+ }
72
+
73
+ .my-toast {
74
+ @include cia.toast-base;
75
+ position: relative;
76
+ overflow: hidden;
77
+ pointer-events: auto;
78
+ align-items: flex-start;
79
+ border-inline-start: 4px solid var(--my-toast-accent, #{cia.color(info-default)});
80
+ @include cia.animate(slide-up, fast);
81
+
82
+ &[data-status="info"] { --my-toast-accent: #{cia.color(info-default)}; }
83
+ &[data-status="success"] { --my-toast-accent: #{cia.color(success-default)}; }
84
+ &[data-status="warning"] { --my-toast-accent: #{cia.color(warning-default)}; }
85
+ &[data-status="error"] { --my-toast-accent: #{cia.color(error-default)}; }
86
+
87
+ &[data-leaving] {
88
+ @include cia.animate(fade-out, fast);
89
+ }
90
+
91
+ [data-slot="icon"] {
92
+ flex: none;
93
+ color: var(--my-toast-accent);
94
+ @include cia.font(semibold, 3);
95
+ }
96
+
97
+ [data-slot="body"] {
98
+ flex: 1;
99
+ min-inline-size: 0;
100
+ @include cia.stack($gap: 2xs);
101
+ }
102
+
103
+ [data-slot="title"] {
104
+ @include cia.font(semibold, 2);
105
+ }
106
+
107
+ [data-slot="message"] {
108
+ color: cia.color(text-secondary);
109
+ }
110
+
111
+ [data-slot="action"] {
112
+ @include cia.btn(ghost);
113
+ flex: none;
114
+ }
115
+
116
+ [data-slot="close"] {
117
+ @include cia.btn-icon($size: 2rem);
118
+ flex: none;
119
+ color: cia.color(text-muted);
120
+ }
121
+
122
+ // Countdown bar: JS sets `--my-toast-duration`; hover/focus pauses it.
123
+ [data-slot="progress"] {
124
+ position: absolute;
125
+ inset-block-end: 0;
126
+ inset-inline-start: 0;
127
+ block-size: 3px;
128
+ inline-size: 100%;
129
+ background: var(--my-toast-accent);
130
+ transform-origin: left;
131
+ animation: my-toast-countdown var(--my-toast-duration, 5s) linear forwards;
132
+ }
133
+
134
+ &:hover [data-slot="progress"],
135
+ &:focus-within [data-slot="progress"] {
136
+ animation-play-state: paused;
137
+ }
138
+ }
139
+
140
+ @keyframes my-toast-countdown {
141
+ from { transform: scaleX(1); }
142
+ to { transform: scaleX(0); }
143
+ }
144
+
145
+ @media (prefers-reduced-motion: reduce) {
146
+ .my-toast [data-slot="progress"] {
147
+ animation: none;
148
+ opacity: 0.4;
149
+ }
150
+ }
151
+ ```
152
+
153
+ `cia.toast-base` gives the surface, shadow, radius, padding and type; the recipe only adds the position, the status accent and the countdown bar. `cia.animate()` handles `prefers-reduced-motion` on its own (it collapses the enter/exit animation to ~0ms); the countdown bar is a hand-written keyframe, so its reduced-motion rule is written out explicitly.
154
+
155
+ ## Interactivity
156
+
157
+ The browser can't do this one without JS — there's no native "auto-dismiss" primitive — but the script is small and has exactly four jobs:
158
+
159
+ 1. **Push** — create a toast element, set `role`/`data-status`, append it to the region, start a timer for `duration` ms (default 5000; use `0` to mean "sticky, close manually").
160
+ 2. **Pause / resume** — on `mouseenter` and `focusin` clear the timer and remember how much time was left; on `mouseleave` and `focusout` restart with the remainder. The CSS `animation-play-state: paused` on hover/focus-within keeps the bar in step for free.
161
+ 3. **Dismiss** — set `data-leaving`, wait for the exit animation's `animationend` (or a short fallback timeout for reduced-motion users), then remove the element. Called by the timer, the close button, or an action button.
162
+ 4. **Cap the stack** — when more than `max` (say 3) toasts are visible, dismiss the oldest first. An unbounded stack is a bug, not a feature.
163
+
164
+ Edge cases:
165
+
166
+ - **Reduced motion.** `animationend` never fires when `animation-duration` is `0.01ms` in a reliable way across engines — always race it against a `setTimeout` fallback so the element is removed either way.
167
+ - **SSR.** The region renders empty on the server; toasts only ever exist client-side. Never read `window` or start a timer at module load.
168
+ - **Unmount.** Clear every pending timer when the region unmounts (framework examples below all do) or a toast fires `onDismiss` into a dead component.
169
+ - **Same message twice.** Pushing an identical toast while one is visible should *reset its timer*, not stack a duplicate — dedupe on `title + message`.
170
+
171
+ ## A11y checklist
172
+
173
+ - [ ] Info/success toasts use `role="status"`; warning/error toasts use `role="alert"` — assertive interruptions are reserved for things the user must know now ([WAI-ARIA 1.2: status role](https://www.w3.org/TR/wai-aria-1.2/#status), [alert role](https://www.w3.org/TR/wai-aria-1.2/#alert))
174
+ - [ ] The notification region is present in the DOM before the first toast is pushed, so live-region announcements are reliable ([WAI-ARIA APG: Alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/))
175
+ - [ ] Auto-dismiss pauses on hover and on keyboard focus, and any toast with an action gives the user enough time to reach it — or doesn't auto-dismiss at all ([WCAG 2.2 SC 2.2.1 Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html))
176
+ - [ ] Severity is conveyed by the icon, the title and the role — never by the accent colour alone ([WCAG 2.2 SC 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html))
177
+ - [ ] The close button has an accessible name ("Dismiss notification"), and every action is a real `<button>` reachable by keyboard ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
178
+ - [ ] Toasts don't steal focus when they appear — focus stays where the user was working; the toast is announced, not focused ([WCAG 2.2 SC 3.2.1 On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html))
179
+ - [ ] Enter/exit motion respects `prefers-reduced-motion` ([WCAG 2.2 SC 2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html))
180
+
181
+ ## Framework examples
182
+
183
+ All four implement the same spec: a `toast()` function, a stacked region, per-toast auto-dismiss with pause-on-hover/focus, a close button and an optional action.
184
+
185
+ ### React
186
+
187
+ ```tsx
188
+ "use client";
189
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
190
+ import styles from "./Toasts.module.scss";
191
+
192
+ type Status = "info" | "success" | "warning" | "error";
193
+ type Toast = {
194
+ id: number;
195
+ status: Status;
196
+ title: string;
197
+ message?: string;
198
+ duration: number;
199
+ action?: { label: string; onClick: () => void };
200
+ };
201
+
202
+ const ToastContext = createContext<(t: Omit<Toast, "id" | "duration"> & { duration?: number }) => void>(() => {});
203
+ export const useToast = () => useContext(ToastContext);
204
+
205
+ const ICONS: Record<Status, string> = { info: "i", success: "✓", warning: "!", error: "!" };
206
+ const MAX = 3;
207
+
208
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
209
+ const [toasts, setToasts] = useState<Toast[]>([]);
210
+ const nextId = useRef(0);
211
+
212
+ const push = useCallback((t: Omit<Toast, "id" | "duration"> & { duration?: number }) => {
213
+ setToasts((list) => {
214
+ const next = [...list, { ...t, id: nextId.current++, duration: t.duration ?? 5000 }];
215
+ return next.slice(-MAX); // cap the stack, oldest first
216
+ });
217
+ }, []);
218
+
219
+ const dismiss = useCallback((id: number) => setToasts((list) => list.filter((t) => t.id !== id)), []);
220
+
221
+ return (
222
+ <ToastContext.Provider value={push}>
223
+ {children}
224
+ {/* Region is always rendered, even when empty — see A11y checklist */}
225
+ <section className={styles.myToasts} aria-label="Notifications">
226
+ {toasts.map((t) => (
227
+ <ToastItem key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
228
+ ))}
229
+ </section>
230
+ </ToastContext.Provider>
231
+ );
232
+ }
233
+
234
+ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
235
+ const remaining = useRef(toast.duration);
236
+ const startedAt = useRef(0);
237
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
238
+
239
+ const start = useCallback(() => {
240
+ if (toast.duration === 0) return;
241
+ startedAt.current = Date.now();
242
+ timer.current = setTimeout(onDismiss, remaining.current);
243
+ }, [toast.duration, onDismiss]);
244
+
245
+ const pause = useCallback(() => {
246
+ if (!timer.current) return;
247
+ clearTimeout(timer.current);
248
+ timer.current = null;
249
+ remaining.current -= Date.now() - startedAt.current;
250
+ }, []);
251
+
252
+ useEffect(() => {
253
+ start();
254
+ return () => {
255
+ if (timer.current) clearTimeout(timer.current);
256
+ };
257
+ }, [start]);
258
+
259
+ const assertive = toast.status === "warning" || toast.status === "error";
260
+
261
+ return (
262
+ <div
263
+ className={styles.myToast}
264
+ role={assertive ? "alert" : "status"}
265
+ data-status={toast.status}
266
+ style={{ "--my-toast-duration": `${toast.duration}ms` } as React.CSSProperties}
267
+ onMouseEnter={pause}
268
+ onMouseLeave={start}
269
+ onFocus={pause}
270
+ onBlur={start}
271
+ >
272
+ <span data-slot="icon" aria-hidden="true">{ICONS[toast.status]}</span>
273
+ <div data-slot="body">
274
+ <strong data-slot="title">{toast.title}</strong>
275
+ {toast.message && <span data-slot="message">{toast.message}</span>}
276
+ </div>
277
+ {toast.action && (
278
+ <button type="button" data-slot="action" onClick={() => { toast.action?.onClick(); onDismiss(); }}>
279
+ {toast.action.label}
280
+ </button>
281
+ )}
282
+ <button type="button" data-slot="close" aria-label="Dismiss notification" onClick={onDismiss}>
283
+ ×
284
+ </button>
285
+ {toast.duration > 0 && <span data-slot="progress" aria-hidden="true" />}
286
+ </div>
287
+ );
288
+ }
289
+ ```
290
+
291
+ The `style` attribute here sets only the `--my-toast-duration` custom property that the countdown bar reads — a per-instance value that can't come from a stylesheet, not an appearance override.
292
+
293
+ ### Vue
294
+
295
+ ```vue
296
+ <script setup>
297
+ import { ref, onBeforeUnmount } from "vue";
298
+
299
+ const MAX = 3;
300
+ const ICONS = { info: "i", success: "✓", warning: "!", error: "!" };
301
+ const toasts = ref([]);
302
+ const timers = new Map(); // id → { handle, remaining, startedAt }
303
+ let nextId = 0;
304
+
305
+ function dismiss(id) {
306
+ const t = timers.get(id);
307
+ if (t?.handle) clearTimeout(t.handle);
308
+ timers.delete(id);
309
+ toasts.value = toasts.value.filter((x) => x.id !== id);
310
+ }
311
+ function start(id) {
312
+ const t = timers.get(id);
313
+ if (!t || t.remaining <= 0) return;
314
+ t.startedAt = Date.now();
315
+ t.handle = setTimeout(() => dismiss(id), t.remaining);
316
+ }
317
+ function pause(id) {
318
+ const t = timers.get(id);
319
+ if (!t?.handle) return;
320
+ clearTimeout(t.handle);
321
+ t.handle = null;
322
+ t.remaining -= Date.now() - t.startedAt;
323
+ }
324
+ function push({ status = "info", title, message, duration = 5000, action }) {
325
+ const id = nextId++;
326
+ toasts.value = [...toasts.value, { id, status, title, message, duration, action }].slice(-MAX);
327
+ if (duration > 0) {
328
+ timers.set(id, { handle: null, remaining: duration, startedAt: 0 });
329
+ start(id);
330
+ }
331
+ }
332
+ onBeforeUnmount(() => timers.forEach((t) => t.handle && clearTimeout(t.handle)));
333
+
334
+ defineExpose({ push });
335
+ </script>
336
+
337
+ <template>
338
+ <section class="my-toasts" aria-label="Notifications">
339
+ <div
340
+ v-for="t in toasts"
341
+ :key="t.id"
342
+ class="my-toast"
343
+ :role="t.status === 'warning' || t.status === 'error' ? 'alert' : 'status'"
344
+ :data-status="t.status"
345
+ :style="{ '--my-toast-duration': t.duration + 'ms' }"
346
+ @mouseenter="pause(t.id)"
347
+ @mouseleave="start(t.id)"
348
+ @focusin="pause(t.id)"
349
+ @focusout="start(t.id)"
350
+ >
351
+ <span data-slot="icon" aria-hidden="true">{{ ICONS[t.status] }}</span>
352
+ <div data-slot="body">
353
+ <strong data-slot="title">{{ t.title }}</strong>
354
+ <span v-if="t.message" data-slot="message">{{ t.message }}</span>
355
+ </div>
356
+ <button v-if="t.action" type="button" data-slot="action" @click="t.action.onClick(); dismiss(t.id)">
357
+ {{ t.action.label }}
358
+ </button>
359
+ <button type="button" data-slot="close" aria-label="Dismiss notification" @click="dismiss(t.id)">×</button>
360
+ <span v-if="t.duration > 0" data-slot="progress" aria-hidden="true"></span>
361
+ </div>
362
+ </section>
363
+ </template>
364
+ ```
365
+
366
+ ### Svelte
367
+
368
+ ```svelte
369
+ <script>
370
+ import { onDestroy } from "svelte";
371
+
372
+ const MAX = 3;
373
+ const ICONS = { info: "i", success: "✓", warning: "!", error: "!" };
374
+ let toasts = [];
375
+ const timers = new Map();
376
+ let nextId = 0;
377
+
378
+ function dismiss(id) {
379
+ const t = timers.get(id);
380
+ if (t?.handle) clearTimeout(t.handle);
381
+ timers.delete(id);
382
+ toasts = toasts.filter((x) => x.id !== id);
383
+ }
384
+ function start(id) {
385
+ const t = timers.get(id);
386
+ if (!t || t.remaining <= 0) return;
387
+ t.startedAt = Date.now();
388
+ t.handle = setTimeout(() => dismiss(id), t.remaining);
389
+ }
390
+ function pause(id) {
391
+ const t = timers.get(id);
392
+ if (!t?.handle) return;
393
+ clearTimeout(t.handle);
394
+ t.handle = null;
395
+ t.remaining -= Date.now() - t.startedAt;
396
+ }
397
+ export function push({ status = "info", title, message, duration = 5000, action }) {
398
+ const id = nextId++;
399
+ toasts = [...toasts, { id, status, title, message, duration, action }].slice(-MAX);
400
+ if (duration > 0) {
401
+ timers.set(id, { handle: null, remaining: duration, startedAt: 0 });
402
+ start(id);
403
+ }
404
+ }
405
+ onDestroy(() => timers.forEach((t) => t.handle && clearTimeout(t.handle)));
406
+ </script>
407
+
408
+ <section class="my-toasts" aria-label="Notifications">
409
+ {#each toasts as t (t.id)}
410
+ <div
411
+ class="my-toast"
412
+ role={t.status === "warning" || t.status === "error" ? "alert" : "status"}
413
+ data-status={t.status}
414
+ style="--my-toast-duration: {t.duration}ms"
415
+ on:mouseenter={() => pause(t.id)}
416
+ on:mouseleave={() => start(t.id)}
417
+ on:focusin={() => pause(t.id)}
418
+ on:focusout={() => start(t.id)}
419
+ >
420
+ <span data-slot="icon" aria-hidden="true">{ICONS[t.status]}</span>
421
+ <div data-slot="body">
422
+ <strong data-slot="title">{t.title}</strong>
423
+ {#if t.message}<span data-slot="message">{t.message}</span>{/if}
424
+ </div>
425
+ {#if t.action}
426
+ <button type="button" data-slot="action" on:click={() => { t.action.onClick(); dismiss(t.id); }}>
427
+ {t.action.label}
428
+ </button>
429
+ {/if}
430
+ <button type="button" data-slot="close" aria-label="Dismiss notification" on:click={() => dismiss(t.id)}>×</button>
431
+ {#if t.duration > 0}<span data-slot="progress" aria-hidden="true"></span>{/if}
432
+ </div>
433
+ {/each}
434
+ </section>
435
+ ```
436
+
437
+ ### Vanilla (Web Component)
438
+
439
+ ```js
440
+ const ICONS = { info: "i", success: "✓", warning: "!", error: "!" };
441
+ const MAX = 3;
442
+
443
+ class MyToasts extends HTMLElement {
444
+ connectedCallback() {
445
+ this.setAttribute("role", "region");
446
+ this.setAttribute("aria-label", "Notifications");
447
+ this.classList.add("my-toasts");
448
+ }
449
+
450
+ push({ status = "info", title, message = "", duration = 5000, action } = {}) {
451
+ while (this.children.length >= MAX) this.dismiss(this.firstElementChild);
452
+
453
+ const el = document.createElement("div");
454
+ el.className = "my-toast";
455
+ el.dataset.status = status;
456
+ el.setAttribute("role", status === "warning" || status === "error" ? "alert" : "status");
457
+ el.style.setProperty("--my-toast-duration", `${duration}ms`);
458
+ el.innerHTML = `
459
+ <span data-slot="icon" aria-hidden="true">${ICONS[status]}</span>
460
+ <div data-slot="body">
461
+ <strong data-slot="title"></strong>
462
+ <span data-slot="message"></span>
463
+ </div>
464
+ ${action ? '<button type="button" data-slot="action"></button>' : ""}
465
+ <button type="button" data-slot="close" aria-label="Dismiss notification">×</button>
466
+ ${duration > 0 ? '<span data-slot="progress" aria-hidden="true"></span>' : ""}`;
467
+ el.querySelector('[data-slot="title"]').textContent = title;
468
+ el.querySelector('[data-slot="message"]').textContent = message;
469
+ el.querySelector('[data-slot="close"]').addEventListener("click", () => this.dismiss(el));
470
+ if (action) {
471
+ const btn = el.querySelector('[data-slot="action"]');
472
+ btn.textContent = action.label;
473
+ btn.addEventListener("click", () => { action.onClick(); this.dismiss(el); });
474
+ }
475
+
476
+ // Timer with pause/resume on hover + focus.
477
+ let remaining = duration, startedAt = 0, handle = null;
478
+ const start = () => {
479
+ if (remaining <= 0) return;
480
+ startedAt = Date.now();
481
+ handle = setTimeout(() => this.dismiss(el), remaining);
482
+ };
483
+ const pause = () => {
484
+ if (!handle) return;
485
+ clearTimeout(handle);
486
+ handle = null;
487
+ remaining -= Date.now() - startedAt;
488
+ };
489
+ el.addEventListener("mouseenter", pause);
490
+ el.addEventListener("mouseleave", start);
491
+ el.addEventListener("focusin", pause);
492
+ el.addEventListener("focusout", start);
493
+ el._stop = () => handle && clearTimeout(handle);
494
+
495
+ this.append(el);
496
+ if (duration > 0) start();
497
+ return el;
498
+ }
499
+
500
+ dismiss(el) {
501
+ if (!el || el.hasAttribute("data-leaving")) return;
502
+ el._stop?.();
503
+ el.setAttribute("data-leaving", "");
504
+ // Race animationend against a fallback so reduced-motion users aren't stuck.
505
+ const remove = () => el.remove();
506
+ el.addEventListener("animationend", remove, { once: true });
507
+ setTimeout(remove, 400);
508
+ }
509
+ }
510
+ customElements.define("my-toasts", MyToasts);
511
+
512
+ // Usage: <my-toasts></my-toasts> once at the app root, then
513
+ // document.querySelector("my-toasts").push({ status: "success", title: "Saved" });
514
+ ```
515
+
516
+ ## Variants
517
+
518
+ ### Top layer via `[popover="manual"]`
519
+
520
+ If the toast region can be covered by a `<dialog>` or another stacking context you don't control, promote it to the browser's top layer with the Popover API — `popover="manual"` means no light-dismiss and no Esc-to-close, exactly what a persistent region wants. Browser floor: Chrome ≥114, Safari ≥17, Firefox ≥125; the `position: fixed` default above is the fallback everywhere else.
521
+
522
+ ```html
523
+ <section data-cia-recipe="toast" aria-label="Notifications" data-slot="region" popover="manual"></section>
524
+ ```
525
+
526
+ ```scss
527
+ @use 'css-is-awesome/api' as cia;
528
+
529
+ .my-toasts[popover] {
530
+ // Popovers reset to the centre of the viewport with a border and padding —
531
+ // re-pin the region and clear the UA styling.
532
+ inset: auto cia.space(4) cia.space(4) auto;
533
+ margin: 0;
534
+ padding: 0;
535
+ border: 0;
536
+ background: transparent;
537
+ overflow: visible;
538
+ }
539
+ ```
540
+
541
+ Call `region.showPopover()` once at startup (guarded by `"showPopover" in region`) and leave it open; `z-index` is irrelevant in the top layer.
542
+
543
+ ### Top-of-screen placement
544
+
545
+ Swap the block edge — useful when the bottom of the viewport is already occupied by a dock or a bottom nav (see the [`bottom-nav`](./bottom-nav.md) recipe).
546
+
547
+ ```scss
548
+ @use 'css-is-awesome/api' as cia;
549
+
550
+ .my-toasts {
551
+ inset-block-end: auto;
552
+ inset-block-start: cia.space(4);
553
+ }
554
+ .my-toast {
555
+ @include cia.animate(slide-down, fast);
556
+ }
557
+ ```
558
+
559
+ ## Pitfalls
560
+
561
+ - **Don't put `aria-live` on the region and `role="alert"` on the toasts.** Nesting live regions makes some screen readers announce twice and others not at all. One live role, on the toast, is enough.
562
+ - **Don't mount the region lazily with the first toast.** The first notification is the one that gets swallowed — the region has to exist first.
563
+ - **Don't auto-dismiss a toast that carries an action** unless the timer is generous and pauses on hover/focus. A "Undo" that vanishes before the user can reach it fails WCAG 2.2.1 and, worse, feels like a trap.
564
+ - **Don't rely on `animationend` alone to remove the element.** With `prefers-reduced-motion` (or a stylesheet that never loaded) the event may never fire and the toast stays forever — always race a fallback timeout.
565
+ - **Don't let the stack grow unbounded.** Cap it (3 is plenty) and drop the oldest; a wall of ten toasts is noise, not feedback.
566
+
567
+ ## Related recipes
568
+
569
+ - [`confirm-dialog`](./confirm-dialog.md) — for feedback that needs a decision before the user can continue
570
+ - [`form-validation-async`](./form-validation-async.md) — a natural producer of success/error toasts after a server round-trip
571
+ - [`bottom-nav`](./bottom-nav.md) — when the bottom edge is taken, use the top-of-screen variant above