ToastNotification
Stackable global alerts with actions.
Live demo
01Click any button β toasts dismiss automatically (except Persistent).
Live playground
Tweak the props below β they bind straight into the live container.
In context β settings panel
Saving from a form is the canonical toast trigger. Click Save to see the success toast appear without taking focus away.
Implementation
02<script lang="ts">
import ToastNotification from '$lib/components/ToastNotification.svelte';
import { addToast } from '$lib/toast.svelte';
</script>
β
<ToastNotification position="top-right" />
<button onclick={() => addToast({ message: 'Saved!', severity: 'success' })}>Save</button>ToastNotification renders the on-screen container; toast state lives in a Svelte 5 module-script rune singleton. Anywhere in your app, call addToast(...) to push a new entry β the component subscribes reactively, ARIA-live announces it politely (or assertively for errors), and a setTimeout removes it after the configured duration. Errors and persistent toasts (duration: 0) stay until dismissed.
Logic explainer
03What Does It Do? (Plain English)
ToastNotification is a global, non-blocking alert system. You mount one container in your root layout, then call addToast({ message, severity }) from anywhere β a button handler, a fetch error path, a server action β and a small card slides in at the corner of the screen, lives for a few seconds, and silently goes away. Multiple toasts stack up; users can dismiss them with Γ, with Escape, or by clicking the optional action button.
Think of it as a dispatcher and a bulletin board: anything in your app can drop a message on the board, and the board takes care of styling, stacking, announcing to assistive tech, and tidying up after itself.
How It Works (Pseudo-Code)
module-level singleton state:
toastState.stack = [] // array of active toasts (Svelte 5 $state)
addToast(toast):
id = toast.id or random-string
newToast = merge(toast, sensible defaults)
push newToast onto toastState.stack
if newToast.duration > 0:
schedule setTimeout β dismissToast(id) after duration ms
return id
dismissToast(id):
index = stack.findIndex by id
if found: splice it out of stack
container component:
derive displayedToasts = stack.slice(-maxVisible) // newest N
on Escape key (window):
if displayedToasts non-empty and last is dismissible:
dismissToast(last.id)
for each toast in displayedToasts:
role = toast.severity == 'error' ? 'alert' : 'status'
aria-live = error ? 'assertive' : 'polite'
render with severity icon, message, optional action button, optional Γ
on action click: run callback, then dismissToast(id)
on Γ click: dismissToast(id)The Core Concept: A Module-Level Singleton
The whole point of a toast system is that anyone can fire a toast without prop drilling, context wiring, or import gymnastics. The component achieves that with a Svelte 5 trick that's easy to miss: state declared at module scope inside a .svelte.ts file is shared across the whole app.
src/lib/toast.svelte.ts
β
export const toastState = $state({ stack: [] }) // ONE instance, shared everywhere
export function addToast(...) { toastState.stack.push(...) }
export function dismissToast(id) { ... }Any component, server-action result handler, or utility module that imports addToast mutates the same array. The <ToastNotification /> container also imports toastState and reads stack reactively β so a push from anywhere triggers a re-render exactly where it needs to.
The .svelte.ts extension is what unlocks rune syntax outside of components. Without it, $state(...) would be a syntax error.
This is a singleton by file, not a singleton by class. There's no getInstance(), no provider, no React-style context. Module identity is the lock.
Auto-Dismiss Lifecycle
Each toast stores its dismiss schedule as a setTimeout. When addToast runs:
- The toast is appended to
stackimmediately β the user sees it on the next tick. - If
duration > 0, asetTimeout(() => dismissToast(id), duration)is scheduled. - When the timer fires,
dismissToastlooks the entry up by id and splices it out.
A few subtle properties fall out of this design:
duration: 0makes a toast sticky. No timer is scheduled, so it stays until the user dismisses it (or the page unloads). Useful for "this connection failed, please reconnect" alerts that the user must see.- Manual dismiss races safely. If the user clicks
Γbefore the timer fires, the toast is removed from the stack; when the late timer eventually fires,findIndexreturns-1anddismissToastis a no-op. No exception, no double-removal. - Pause-on-hover is intentionally absent. Browser timers keep ticking under the cursor; we accept that toasts may dismiss during a hover read because the alternative β pausing/resuming hundreds of timers β adds complexity for a marginal UX win.
XSS Protection
The toast message string is interpolated as text content ({toast.message}), not as HTML. Svelte's {...} interpolation escapes by default, so a message of "<script>alert(1)</script>" renders literal angle brackets β never executed. The action label is the same.
The severity icons are SVG path strings stored in a constant inside the component, not user-supplied, so there's no {@html} boundary to defend.
CSS Animation Strategy
Each toast slides into place using Svelte's built-in fly and fade transitions:
in:fly={{ y: position.startsWith('top') ? -20 : 20, duration: 300 }}
out:fade={{ duration: 200 }}Top-anchored stacks fly down into view (the Y offset is negative, so they start above the final position). Bottom-anchored stacks fly up. The vertical gap and flex-direction: column-reverse for bottom positions keep newest toasts always closest to the screen edge.
The pointer-events: none on the container is critical. Without it, the invisible 400-px-wide column the container occupies would block clicks on whatever sits underneath (a navbar, a dropdown). Each toast then re-enables pointer events on itself with pointer-events: auto. The result: the user can click straight through the empty space, but interact with any visible toast.
prefers-reduced-motion: reduce zeroes out both the slide and the fade β toasts pop in and out without motion cues. The text-only severity colour border and live region announcement still convey the toast's meaning.
State Flow Diagram
ββββββββββββββββββββ
β caller invokes β
β addToast(...) β
ββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββββ
β push to β
β toastState.stack β
β schedule timeout β
ββββββββββ¬ββββββββββββ
β reactive update
βΌ
ββββββββββββββββββββββ
β TOAST VISIBLE β
β in container β
β (last maxVisible) β
ββββββββββ¬ββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ¬βββββββββββββββ
β click Γ β click action β Escape key β timer fires
β β β β
βΌ βΌ βΌ βΌ
ββββββββββββββββββββββ
β dismissToast(id) β
β splice from stack β
ββββββββββ¬ββββββββββββ
β
βΌ
βββββββββββ
β GONE β
βββββββββββProps Reference
Container (<ToastNotification />)
| Prop | Type | Default | Description |
|---|---|---|---|
position |
'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' |
'top-right' |
Screen corner where toasts stack. |
maxVisible |
number |
5 |
Maximum number of toasts shown simultaneously; older ones drop off the back. |
offsetY |
string |
'1rem' |
Vertical inset from the chosen edge β bump it up to clear sticky navbars. |
offsetX |
string |
'1rem' |
Horizontal inset from the chosen edge. |
class |
string |
'' |
Extra classes appended to the container. |
addToast(data: ToastData)
| Field | Type | Default | Description |
|---|---|---|---|
message |
string |
required | Text body shown in the toast. |
severity |
'success' | 'error' | 'warning' | 'info' |
'info' |
Drives the icon, accent stripe, and ARIA role. |
duration |
number |
5000 |
Milliseconds before auto-dismiss; 0 disables auto-dismiss. |
dismissible |
boolean |
true |
Render the Γ close button. |
action |
{ label: string; onclick: () => void } |
undefined |
Optional inline button β clicking runs the callback then dismisses. |
id |
string |
random | Caller-supplied id. Useful when you want to dedupe by passing the same id again. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Caller fires the same toast id twice in quick succession | Both push onto the stack β there is no de-dupe by id at the API layer. Pass a stable id and dismiss the previous one yourself if dedupe matters. |
duration: 0 and the user never dismisses |
The toast persists for the lifetime of the page; nothing leaks because the entry sits in a single in-memory array. |
The user clicks Γ before the auto-dismiss timer fires |
The entry is removed immediately; when the timer eventually fires, findIndex returns -1 and dismissToast exits without throwing. |
More than maxVisible toasts queue up |
Only the newest maxVisible render. Older entries remain in stack but aren't visible. They still auto-dismiss on their own timers. |
severity: 'error' is fired |
The toast is rendered with role="alert" and aria-live="assertive" so screen readers interrupt to announce it; other severities are role="status" / aria-live="polite". |
| Escape pressed with multiple visible toasts | Only the most recent dismissible toast is closed. Press again to keep dismissing from the newest. |
prefers-reduced-motion: reduce is set |
Slide-in / fade-out animations are removed; toasts appear and disappear instantly. |
| Container mounted twice (e.g. nested layouts) | Both render the same toastState.stack β every toast appears in both places. Mount the container once at the root. |
Dependencies
- Svelte 5.x β
$state,$derived,$effect, snippets, and especially the rune-enabled.svelte.tsmodule that backs the singleton. svelte/transitionβ built-inflyandfadetransitions for slide-in / fade-out.- Zero external runtime dependencies. Severity icons are inline SVG paths.
File Structure
src/lib/components/ToastNotification.svelte # container component
src/lib/components/ToastNotification.md # this file (rendered inside ComponentPageShell)
src/lib/toast.svelte.ts # module-level state + addToast/dismissToast API
src/lib/types.ts # ToastData, ToastSeverity, ToastPosition, ToastNotificationProps
src/routes/toastnotification/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
position | ToastPosition | "top-right" | Screen anchor: top-left, top-right, top-center, bottom-left, bottom-right, bottom-center. |
maxVisible | number | 5 | Maximum toasts displayed simultaneously. Older entries are kept in state but hidden. |
offsetY | string | "1rem" | Distance from the top/bottom edge β handy to clear a sticky navbar. |
offsetX | string | "1rem" | Distance from the left/right edge. |
class | string | "" | Extra class names forwarded to the container element. |