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.

The whole API
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 everything

Why 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:

No component, no ancestor, no context
// 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 Viewport once 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 carries aria-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 max the 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.

PropTypeDefault
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.number5
dismissLabel Base label for each dismiss button, for translation.string'Dismiss'
class Extra classes merged onto the region.string