Toasts
A toast reports that a background action finished or failed. It appears at the window’s edge, does not block the page and does not take focus. Also show every error where it happened, for example in the object’s state.
Illustrative example with synthetic data. A brief notice confirms a background action at the edge of the page.
When to use a toast
Section titled “When to use a toast”Use one to confirm an action whose result is not otherwise visible, or to summarise failures across the page in one notice.
| The page needs | Use |
|---|---|
| A decision before the reader goes on | A confirmation dialog |
| The state of one object | Object states |
| A failed start-up | The boot loader |
| A result of an action in the data dialog | The dialog itself; see Open the dialog immediately |
Choose a kind
Section titled “Choose a kind”- Did something the reader asked for fail, or does something need their attention? Use an error.
- Otherwise, use a notice.
| Kind | Region | Dismissal | Common use cases |
|---|---|---|---|
| Notice | role="status" |
After 5 seconds; paused on hover and focus | “Revenue by region.xlsx downloaded.” “Value copied.” |
| Error | role="alert" |
Only by the reader | “The image could not be exported.” “3 objects could not be refreshed.” |
Markup
Section titled “Markup”<!-- At the end of <body>, outside every dialog --><section class="toasts" aria-label="Notifications"> <div class="toasts__list" role="status"></div> <div class="toasts__list" role="alert"></div></section>- Create both regions at page load. A live region created together with its content is often not announced.
- Name the object in the text (“Revenue by region”), so several toasts can be told apart.
:root { --toast-w: 360px; /* Set to the height of anything fixed along the bottom edge. */ --toast-bottom: 0px;}
.toasts { position: fixed; inset: auto var(--gap) calc(var(--gap) + var(--toast-bottom)) auto; z-index: 10; display: grid; gap: 8px; width: min(var(--toast-w), 100% - 2 * var(--gap)); /* The region takes no clicks; only toasts do. */ pointer-events: none;}.toasts__list { display: grid; gap: 8px;}.toast { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--ink); box-shadow: var(--panel-shadow); pointer-events: auto;}.toasts__list[role='alert'] > .toast { border-inline-start: 4px solid var(--danger);}.toast__text { margin: 0; overflow-wrap: anywhere;}.toast__close { width: 24px; height: 24px;}
@media (prefers-reduced-motion: no-preference) { .toast { animation: toast-in 150ms ease-out; }}@keyframes toast-in { from { opacity: 0; translate: 0 8px; }}
@media (width < 600px) { .toasts { inset-inline: var(--gap); width: auto; }}Place toasts at the bottom, clear of the sticky header and filter bar. The entry animation uses
translate, so it does not replace a placement transform.
Behaviour
Section titled “Behaviour”Show and dismiss
Section titled “Show and dismiss”type Kind = 'notice' | 'error';
const NOTICE_MS = 5000;const MAX_NOTICES = 3;
const lists = { notice: document.querySelector<HTMLElement>('.toasts__list[role="status"]'), error: document.querySelector<HTMLElement>('.toasts__list[role="alert"]'),};
// Move focus out before removing a focused toast, so it is not lost. #main needs tabindex="-1".const removeToast = (item: HTMLElement) => { if (item.contains(document.activeElement)) document.querySelector<HTMLElement>('#main')?.focus(); item.remove();};
export const toast = (text: string, kind: Kind = 'notice') => { const list = lists[kind]; if (!list) return;
const item = document.createElement('div'); item.className = 'toast'; const body = document.createElement('p'); body.className = 'toast__text'; body.textContent = text; const close = document.createElement('button'); close.type = 'button'; close.className = 'toast__close'; close.setAttribute('aria-label', 'Dismiss'); close.textContent = '×'; item.append(body, close);
let timer: ReturnType<typeof setTimeout> | undefined; const dismiss = () => { clearTimeout(timer); removeToast(item); }; close.addEventListener('click', dismiss);
if (kind === 'notice') { const start = () => { clearTimeout(timer); if (item.matches(':hover') || item.contains(document.activeElement)) return; timer = setTimeout(dismiss, NOTICE_MS); }; item.addEventListener('pointerenter', () => clearTimeout(timer)); item.addEventListener('pointerleave', start); item.addEventListener('focusin', () => clearTimeout(timer)); item.addEventListener('focusout', (event) => { if (!(event.relatedTarget instanceof Node) || !item.contains(event.relatedTarget)) start(); }); start(); while (list.children.length >= MAX_NOTICES) { const oldest = list.firstElementChild; if (!(oldest instanceof HTMLElement)) break; removeToast(oldest); } } list.append(item);};- Errors stay until dismissed.
- A toast never takes focus; its close button is reachable with Tab.
- Put no action in a notice, because it disappears on its own. Put actions in an error or in the page.
What to announce
Section titled “What to announce”| Event | Kind | Text |
|---|---|---|
| CSV or Excel file created | Notice | “Revenue by region.csv downloaded.” |
| Image export failed | Error | “The image of Revenue by region could not be exported.” |
| Value copied | Notice | “Copied 1,234.5.” |
| Copy failed | Error | “Copy failed: the browser blocked clipboard access.” |
| Objects failed after an applied change | Error | “3 objects could not be refreshed.” |
- Summarise failures that happen together in one toast.
- Do not announce what the page already shows, such as applied selections.
- Format numbers with the app’s locale; see Numbers: pin one locale.
navigator.clipboard.writeTextrejects when the browser refuses; report both outcomes.
While a dialog is open
Section titled “While a dialog is open”A modal dialog makes the page, including the toast region, inert. Report results of actions taken in a dialog inside that dialog.
Responsive behaviour
Section titled “Responsive behaviour”| Width | Region |
|---|---|
| 600px and wider | Bottom-right, 360px wide |
| Under 600px | Along the bottom, full width less the gaps |