Toast

Notifications backed by a queue rather than a list. One toast is visible at a time and the rest wait their turn, ranked by priority, so a burst of notices can neither bury the one that matters nor scroll past before it has been read.

The queue is a pure reducer — enqueue_toast, dismiss_toast, expire_toasts, activate_toast_action — and ToastStore is the reactive wrapper around it that owns the expiry timer. toast is a ready-made store, so toast.show('Saved') works from anywhere; pass store to <Toast /> when a page wants its own queue. The reducer stands on its own — every function takes a queue and returns the next one plus the lifecycle effects it produced — so an app that wants its own timers and rendering can hold the queue in $state.raw and drive it directly.

Priorities

progress, info, success, warning, error, ranked in that order. A higher-ranked arrival preempts the visible toast and pushes it back into the queue with its remaining reading time banked, so an interrupted notice resumes rather than restarting or being cut short. warning and error stay up until dismissed; the rest time out after five seconds.

svelte<script lang="ts">
  import Toast from '$lib/Toast.svelte'
  import { TOAST_PRIORITIES, ToastStore } from '$lib/toast-queue.svelte.ts'

  const store = new ToastStore()
</script>

<div style="display: flex; gap: 6pt; flex-wrap: wrap">
  {#each TOAST_PRIORITIES as priority (priority)}
    <button onclick={() => store.show(`A ${priority} notification`, { priority })}>
      {priority}
    </button>
  {/each}
  <button onclick={() => store.clear()}>clear</button>
</div>

<Toast {store} />

A ladder of your own

Those five are only the default. Rank is array position, so pass your own ordered list as priorities to create_toast_queue or ToastStore and the types follow from it: show accepts exactly the names you listed, and an unrecognized one throws rather than ranking below every real tier, which is how a stray name silently inverts a queue. Requests that name no priority land on default_priorityinfo where the ladder has that rung, required where it does not — and the top two rungs stay up until dismissed unless sticky_priorities says otherwise. The accent stripe is keyed off data-priority, so custom tiers can be coloured from your own CSS:

const store = new ToastStore({
  priorities: [`progress`, `info`, `action`, `watch`, `error`],
})
store.show(`Watching for changes`, { priority: `watch` })

Actions, dedupe and overflow

A toast can carry a single button. Because dismissing one is easy to do by accident, a toast whose action nobody has taken is exempt from the overflow cap: press spam below and the plain notices are dropped from the queue while the undo survives.

Repeats collapse: a second toast with the same dedupe_key (defaulting to the message) updates the one already queued instead of lining up behind it. A lower-priority repeat refreshes only the text, leaving the original’s priority, timing and action alone.

svelte<script lang="ts">
  import Toast from '$lib/Toast.svelte'
  import { ToastStore } from '$lib/toast-queue.svelte.ts'

  const store = new ToastStore()
  let log = $state<string[]>([])
  const record = (entry: string) => (log = [entry, ...log].slice(0, 5))

  const show_undo = () =>
    store.show(`Deleted "notes.md"`, {
      dedupe_key: `delete-${Math.random()}`,
      duration_ms: null,
      action: { label: `Undo`, on_click: () => record(`undo pressed`) },
      on_close: (toast, reason) => record(`${reason}: ${toast.message}`),
    })
  const spam = () => {
    for (const idx of [1, 2, 3, 4, 5]) {
      store.show(`Background job ${idx} finished`, {
        priority: `success`,
        duration_ms: null,
        on_close: (toast, reason) => record(`${reason}: ${toast.message}`),
      })
    }
  }
</script>

<div style="display: flex; gap: 6pt; flex-wrap: wrap">
  <button onclick={show_undo}>delete a file</button>
  <button onclick={spam}>spam 5 notices</button>
  <button onclick={() => store.show(`Still saving…`, { priority: `progress` })}>
    repeat (deduped)
  </button>
  <button onclick={() => store.clear()}>clear</button>
</div>

<ol>
  {#each log as entry, idx (idx)}<li><code>{entry}</code></li>{/each}
</ol>

<Toast {store} position="top-center" />

Accessibility

Both live regions are mounted up front and stay in the DOM — a region created at the same moment as its first message goes unannounced in several screen readers. warning and error land in the assertive region, everything else in the polite one, rather than flipping aria-live on a single region mid-flight, which is just as unreliable.

The countdown suspends while the pointer is over the stack or the keyboard is inside it, so reaching for the action button cannot make it disappear (WCAG 2.2.1).

Alt+T moves focus to the toast's first control from anywhere on the page, and Escape dismisses — but only once focus is already inside, so it never fights a dialog for the same key.

Styling

position takes any of top/bottom × left/center/right. Everything else is a CSS custom property: --toast-inset, --toast-bg, --toast-color, --toast-border, --toast-radius, --toast-padding, --toast-shadow, --toast-gap, --toast-item-gap, --toast-max-width, --toast-z-index, --toast-font-size, --toast-button-bg, --toast-button-hover-bg, --toast-button-padding, and --toast-success-color, --toast-warning-color, --toast-error-color for the accent stripe (or --toast-accent to set it whatever the priority). --toast-stack-position drops the stack out of fixed for embedding it in a container. Pass a children snippet to render the body yourself; it receives the ToastItem.

svelte<script lang="ts">
  import Toast from '$lib/Toast.svelte'
  import { ToastStore } from '$lib/toast-queue.svelte.ts'

  const store = new ToastStore()
</script>

<button onclick={() => store.show(`Upload complete`, { priority: `success` })}>
  custom body
</button>

<Toast
  {store}
  position="bottom-left"
  style="--toast-bg: darkslateblue; --toast-color: white; --toast-radius: 2em"
>
  {#snippet children(item)}
    <strong>{item.priority}</strong>
    <span>{item.message}</span>
  {/snippet}
</Toast>