Components Feedback & Identity ToastNotification

ToastNotification

Stackable global alerts with actions.

Live demo

01

Click any button β€” toasts dismiss automatically (except Persistent).

Live playground

Tweak the props below β€” they bind straight into the live container.

Position
Max visible 5
Offset Y 5rem
Offset X 1rem

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
ToastNotification.svelte
<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

03

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

  1. The toast is appended to stack immediately β€” the user sees it on the next tick.
  2. If duration > 0, a setTimeout(() => dismissToast(id), duration) is scheduled.
  3. When the timer fires, dismissToast looks the entry up by id and splices it out.

A few subtle properties fall out of this design:

  • duration: 0 makes 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, findIndex returns -1 and dismissToast is 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.ts module that backs the singleton.
  • svelte/transition β€” built-in fly and fade transitions 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 page

API

04
PropTypeDefaultDescription
positionToastPosition"top-right"Screen anchor: top-left, top-right, top-center, bottom-left, bottom-right, bottom-center.
maxVisiblenumber5Maximum toasts displayed simultaneously. Older entries are kept in state but hidden.
offsetYstring"1rem"Distance from the top/bottom edge β€” handy to clear a sticky navbar.
offsetXstring"1rem"Distance from the left/right edge.
classstring""Extra class names forwarded to the container element.