Skip to main content
On this page

Ephemeral feedback messages that appear in response to user actions. Distinct from the notification center (persistent) - toasts are immediate, temporary, and action-specific.


When to Use

Use Toast Use Notification Center
Form saved successfully New comment on your post
Item deleted System maintenance scheduled
Settings updated Security alert
Error during action Export ready for download
Copied to clipboard Someone mentioned you

Rule: Toasts are for feedback on user-initiated actions. Notifications are for events that happen independently.


Toast Types

Type Icon Use Case Auto-dismiss
success i-lucide-check-circle Action completed 4s
error i-lucide-x-circle Action failed Manual
warning i-lucide-alert-triangle Action succeeded with caveats 6s
info i-lucide-info Neutral information 4s

Wireframe

                                    ┌────────────────────────────────┐
                                    │ ✓ Settings saved               │
                                    │                          [✕]  │
                                    └────────────────────────────────┘

                                    ┌────────────────────────────────┐
                                    │ ✗ Failed to delete item        │
                                    │   Network error. Try again?    │
                                    │                   [Retry] [✕]  │
                                    └────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│                                                                      │
│   Sidebar        Main Content                                        │
│                                                                      │
│                                                                      │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

Position: Top-right corner, below any sticky headers. Stacks vertically with newest on top.


Implementation

Toast Store

// src/lib/stores/toast.svelte.ts
import { SvelteMap } from 'svelte/reactivity';

export type ToastType = 'success' | 'error' | 'warning' | 'info';

export interface Toast {
  id: string;
  type: ToastType;
  title: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
  };
  duration: number; // 0 = manual dismiss only
}

const toasts = new SvelteMap<string, Toast>();

const defaultDurations: Record<ToastType, number> = {
  success: 4000,
  error: 0,      // Errors require manual dismiss
  warning: 6000,
  info: 4000,
};

export function toast(options: Omit<Toast, 'id' | 'duration'> & { duration?: number }) {
  const id = crypto.randomUUID();
  const duration = options.duration ?? defaultDurations[options.type];

  const newToast: Toast = { ...options, id, duration };
  toasts.set(id, newToast);

  if (duration > 0) {
    setTimeout(() => dismiss(id), duration);
  }

  return id;
}

// Convenience methods
toast.success = (title: string, description?: string) =>
  toast({ type: 'success', title, description });

toast.error = (title: string, description?: string) =>
  toast({ type: 'error', title, description });

toast.warning = (title: string, description?: string) =>
  toast({ type: 'warning', title, description });

toast.info = (title: string, description?: string) =>
  toast({ type: 'info', title, description });

export function dismiss(id: string) {
  toasts.delete(id);
}

export function dismissAll() {
  toasts.clear();
}

export function getToasts() {
  return toasts;
}

Toast Container

<!-- src/lib/components/shell/ToastContainer.svelte -->
<script lang="ts">
  import { getToasts, dismiss, type Toast } from '$lib/stores/toast.svelte';
  import { fly } from 'svelte/transition';

  const toasts = getToasts();

  const icons: Record<Toast['type'], string> = {
    success: 'i-lucide-check-circle',
    error: 'i-lucide-x-circle',
    warning: 'i-lucide-alert-triangle',
    info: 'i-lucide-info',
  };
</script>

<div
  class="fixed top-4 right-4 z-toast flex flex-col gap-2 pointer-events-none"
  aria-live="polite"
  aria-label="Notifications"
>
  {#each [...toasts.values()] as toast (toast.id)}
    <div
      class="toast toast-{toast.type} pointer-events-auto"
      role="alert"
      transition:fly={{ x: 100, duration: 200 }}
    >
      <span class={icons[toast.type]} aria-hidden="true" />
      <div class="toast-content">
        <p class="toast-title">{toast.title}</p>
        {#if toast.description}
          <p class="toast-description">{toast.description}</p>
        {/if}
      </div>
      {#if toast.action}
        <button
          class="toast-action"
          onclick={toast.action.onClick}
        >
          {toast.action.label}
        </button>
      {/if}
      <button
        class="toast-dismiss"
        onclick={() => dismiss(toast.id)}
        aria-label="Dismiss"
      >
        <span class="i-lucide-x" />
      </button>
    </div>
  {/each}
</div>

Shell Integration

Add ToastContainer to the root layout:

<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
  import { ToastContainer } from '$lib/components/shell';

  let { children } = $props();
</script>

<div class="app-shell">
  <Sidebar />
  <main>
    {@render children()}
  </main>
  <ToastContainer />
</div>

Usage Examples

Form Submission

<script lang="ts">
  import { toast } from '$lib/stores/toast.svelte';
  import { superForm } from 'sveltekit-superforms';

  const { enhance } = superForm(data.form, {
    onResult({ result }) {
      if (result.type === 'success') {
        toast.success('Settings saved');
      } else if (result.type === 'failure') {
        toast.error('Failed to save settings', result.data?.message);
      }
    },
  });
</script>

Delete with Undo

<script lang="ts">
  import { toast, dismiss } from '$lib/stores/toast.svelte';

  async function deleteItem(id: string) {
    // Optimistic delete
    items = items.filter(item => item.id !== id);

    const toastId = toast({
      type: 'info',
      title: 'Item deleted',
      duration: 5000,
      action: {
        label: 'Undo',
        onClick: async () => {
          await restoreItem(id);
          dismiss(toastId);
          toast.success('Item restored');
        },
      },
    });

    // Actually delete after toast expires
    setTimeout(async () => {
      await permanentlyDeleteItem(id);
    }, 5000);
  }
</script>

API Error with Retry

<script lang="ts">
  import { toast } from '$lib/stores/toast.svelte';

  async function fetchData() {
    try {
      const res = await fetch('/api/data');
      if (!res.ok) throw new Error('Failed to fetch');
      return await res.json();
    } catch (error) {
      toast({
        type: 'error',
        title: 'Failed to load data',
        description: 'Check your connection and try again.',
        action: {
          label: 'Retry',
          onClick: () => fetchData(),
        },
      });
    }
  }
</script>

Clipboard Copy

<script lang="ts">
  import { toast } from '$lib/stores/toast.svelte';

  async function copyToClipboard(text: string) {
    await navigator.clipboard.writeText(text);
    toast.success('Copied to clipboard');
  }
</script>

Stacking Behavior

Scenario Behavior
Multiple toasts Stack vertically, newest on top
Max visible 5 toasts, older ones auto-dismiss
Same message repeated Don't duplicate, extend existing timer
Page navigation Persist toasts (they're in root layout)
// Prevent duplicate toasts
const activeMessages = new Set<string>();

export function toast(options: ToastOptions) {
  const key = `${options.type}:${options.title}`;

  if (activeMessages.has(key)) {
    // Extend existing toast instead of creating new one
    return;
  }

  activeMessages.add(key);
  const id = createToast(options);

  // Cleanup when dismissed
  setTimeout(() => activeMessages.delete(key), options.duration || 10000);

  return id;
}

Styling

/* UnoCSS utilities + custom properties */
.toast {
  --toast-bg: var(--color-surface);
  --toast-border: var(--color-border);
  --toast-icon: var(--color-text-muted);

  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  padding: 1rem;
  background: var(--toast-bg);
  border: 1px solid var(--toast-border);
  border-radius: 0.5rem;
  box-shadow: var(--shadow-lg);
  min-width: 300px;
  max-width: 400px;
}

.toast-success {
  --toast-icon: var(--color-success);
  --toast-border: var(--color-success-border);
}

.toast-error {
  --toast-icon: var(--color-error);
  --toast-border: var(--color-error-border);
}

.toast-warning {
  --toast-icon: var(--color-warning);
  --toast-border: var(--color-warning-border);
}

Accessibility

Requirement Implementation
Screen reader aria-live="polite" on container
Role role="alert" on each toast
Focus Don't steal focus, toasts are informational
Dismiss Button with aria-label="Dismiss"
Reduced motion Respect prefers-reduced-motion for animations
@media (prefers-reduced-motion: reduce) {
  .toast {
    transition: none;
  }
}

Mobile Behavior

Pattern Desktop Mobile
Position Top-right Top-center, full width with padding
Max width 400px 100% - 2rem
Dismiss Click X or action Swipe right or tap X
Stacking 5 visible 3 visible
@media (max-width: 640px) {
  .toast-container {
    left: 1rem;
    right: 1rem;
    max-width: none;
  }

  .toast {
    width: 100%;
    max-width: none;
  }
}

Component Location

src/lib/
├── stores/
│   └── toast.svelte.ts          # Toast state + functions
└── components/
    └── shell/
        └── ToastContainer.svelte # Toast renderer

← Back to Blueprint