Toast
Transient notifications, triggered imperatively.
Usage
Two halves: an imperative trigger you call from anywhere, and a declarative mount point you place once. Without a Toast.Viewport the calls queue into a list nothing is rendering.
Hover or tab into a toast and its countdown freezes. "With action" never dismisses itself.
toast('Copied'); // info
toast.success('Project saved');
toast.warning('Your trial ends in 3 days');
toast.error('Upload failed', { description: 'The file is over 10 MB.' });
// Options
toast('Copied', { duration: 2000 }); // Infinity to keep it
toast('Working…', { dismissible: false });
toast('Message deleted', { // does NOT auto-dismiss
action: { label: 'Undo', onclick: restore }
});
// The id is returned, so a pending toast can be replaced
const id = toast('Uploading…', { duration: Infinity });
await upload();
toast.dismiss(id);
toast.success('Uploaded');
toast.clear(); // drop everythingWhy imperative
Every other component here is declarative, so this one owes you a reason.
A toast does not report state, it reports an event — a fetch
resolved, a socket message arrived, a form action came back. State lives in the component
tree; the event happens inside a catch.
And the argument that settles it is not ergonomics. An imperative call works from code that is not a component at all, where a context-based API simply cannot reach:
// This is the case a declarative API cannot reach: there is no
// component here, and no ancestor to read context from.
export async function api(path, init) {
const res = await fetch(path, init);
if (!res.ok) toast.error(`Request failed (${res.status})`);
return res;
}The declarative alternative also does not save you the hard part — it relocates it. Ids,
auto-dismiss timers, the queue, the limit, and removing a toast only once its exit animation
finished: that is the toast. An {#each} over an array you maintain
hands all of it back to you.
The mount point stays declarative because the app — not the library — decides where the stack sits, how many fit and what the region is called.
Never during SSR
The queue is this library's only mutable module state. On a server, module state is shared across every request — so a toast enqueued while rendering would be delivered in a different user's HTML.
That is not a lost notification, it is one person's message shown to another. So the call is refused on the server and reported to the console. It is reported, not thrown: a toast is by definition not essential to the page, and trading a missing notification for a blank screen is the worse outcome.
Call it from an event handler, from onMount, or from a client-only
module — never from the top level of a load or a component body.
Actions never auto-dismiss
Pass an action and duration defaults to Infinity. A control the user can lose a race against is not a control.
You can still pass an explicit duration — it is a safe default, not a prohibition.
But if the action matters, mirror it somewhere permanent. A toast has no history: miss it and it
is gone.
Always polite
The live region is aria-live="polite" and that is not configurable.
assertive interrupts whatever is being read — and anything that earns
an interruption is too important to auto-dismiss. That is an Alert you
render inline, or an AlertDialog. A toast is for what the user can
afford to miss; if they cannot afford to miss it, it should not be a toast.
Not in v1
- Swipe to dismiss. It needs a keyboard and screen-reader equivalent, and that equivalent is the dismiss button — so the swipe is decoration on top of the control already doing the work.
- Collapsed/stacked animation. It needs FLIP measurement. That is motion, not behaviour, and it is not what was missing.
- A second runtime dependency. None was needed. Enter and exit transitions
come from Svelte, and they respect
prefers-reduced-motion.
Accessibility
- Mount the
Viewportonce and early. It is a persistent live region that renders even when empty, because assistive technology announces additions to a region it was already observing — creating the region and its first toast in the same moment is the usual reason nothing gets announced. - The region is a named
<section>, which is a region; the list inside carriesaria-live.role="region"on an<ol>is a violation — the element already has an implicit list role. - Timers pause on hover and on focus. Someone reading with a screen reader, or tabbing towards the action, is not moving a pointer.
- Each dismiss button is named with its toast's title, not a bare "Dismiss". Three toasts means three buttons, and three identical names give no way to tell which closes what.
- Past
maxthe oldest is dropped — the newest message is the one the user is waiting for. - Never let a toast hold the only copy of information or an action.
Props
Toast.Viewport — the only component. Everything else is the toast function above.
| Prop | Type | Default |
|---|---|---|
label Names the notification region. An unnamed landmark is announced as nothing, and this one is present from first render whether or not there is anything in it. | string | 'Notifications' |
position Where the stack sits. | 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' | 'bottom-right' |
max How many toasts may be on screen. Beyond this the oldest is dropped — the newest message is the one the user is waiting for. | number | 5 |
dismissLabel Base label for each dismiss button, for translation. | string | 'Dismiss' |
class Extra classes merged onto the region. | string | — |