Toast

ui feedbackoverlay

Benachrichtigungs-Toast (success/warn/error/info) mit Akzentstreifen, optionalem Auto-Dismiss und Schließen-Button. ToastStack ist der fixierte Container unten rechts. Store-frei: nimmt ein reines ToastModel entgegen.

    Abhängigkeiten

    Barrierefreiheit

    • ToastStack ist ein <ol> mit aria-live="polite" und aria-relevant="additions" — ohne das kündigt kein Screenreader eine neu erscheinende Meldung an. Nicht weglassen.
    • role="status" für success/info, role="alert" für warn/error.
    • Auto-Dismiss optional (autoDismissMs); warn/error bleiben standardmäßig stehen.
    • Der Schließen-Knopf trägt ein aria-label (Default deutsch, via closeLabel überschreibbar).
    • Reduziert Animation bei prefers-reduced-motion.

    Beispiele

    Stack
    <ToastStack>
      {toasts.map((t) => (
        <Toast key={t.id} toast={t} onDismiss={dismiss} />
      ))}
    </ToastStack>

    Genutzte Tokens · 23

    --accent--border--danger--ease-standard--elev-raised--fg--focus-ring--leading-body--leading-tight--motion-fast--muted--radius-md--radius-sm--space-1--space-2--space-3--space-4--space-6--success--surface--text-sm--text-xs--warn

    Quelldateien

    components/ui/Toast.tsx tsx
    import { useEffect, type ReactNode } from 'react';
    import { CloseIcon } from './Icon';
    import styles from './Toast.module.css';
    
    export type ToastVariant = 'success' | 'error' | 'warn' | 'info';
    
    /** Selbst-enthaltenes Toast-Modell. In der App liefert dies der `toastStore`
     *  (zustand); im Design-System ist es ein reines Datenobjekt ohne Store-Bindung. */
    export interface ToastModel {
      id: string;
      title: string;
      description?: string;
      variant: ToastVariant;
      /** ms bis Auto-Dismiss; `null`/`undefined` = bleibt stehen (User schließt). */
      autoDismissMs?: number | null;
    }
    
    const accentClassFor: Record<ToastVariant, string> = {
      success: styles.accentSuccess,
      warn: styles.accentWarn,
      error: styles.accentError,
      info: styles.accent,
    };
    
    const roleFor: Record<ToastVariant, 'status' | 'alert'> = {
      success: 'status',
      info: 'status',
      warn: 'alert',
      error: 'alert',
    };
    
    interface Props {
      toast: ToastModel;
      onDismiss: (id: string) => void;
      /** Label des Schließen-Buttons (i18n). */
      closeLabel?: string;
    }
    
    export function Toast({ toast, onDismiss, closeLabel = 'Benachrichtigung schließen' }: Props) {
      const { id, autoDismissMs } = toast;
    
      useEffect(() => {
        if (autoDismissMs == null) return;
        const timer = window.setTimeout(() => onDismiss(id), autoDismissMs);
        return () => window.clearTimeout(timer);
      }, [id, autoDismissMs, onDismiss]);
    
      return (
        <li className={styles.toast} role={roleFor[toast.variant]}>
          <div className={accentClassFor[toast.variant]} aria-hidden="true" />
          <div className={styles.body}>
            <div className={styles.title}>{toast.title}</div>
            {toast.description ? (
              <div className={styles.description}>{toast.description}</div>
            ) : null}
          </div>
          <button
            type="button"
            className={styles.close}
            onClick={() => onDismiss(id)}
            aria-label={closeLabel}
          >
            <CloseIcon size={16} />
          </button>
        </li>
      );
    }
    
    /**
     * Fixierter Stapel-Container (unten rechts) für eine Liste von Toasts.
     *
     * `<ol>` statt `<ul>`, weil die Reihenfolge bedeutungstragend ist (neueste
     * zuletzt). Die aria-Attribute sind NICHT optional: ohne `aria-live` kündigt
     * kein Screenreader eine neu erscheinende Meldung an, und `aria-relevant`
     * begrenzt das auf Zugänge — sonst wird beim Auto-Dismiss auch das
     * Verschwinden vorgelesen. Die einzelnen Toasts tragen ihrerseits ein
     * `role` je Variante (alert bei error/warn, status sonst).
     */
    export function ToastStack({ children }: { children: ReactNode }) {
      return (
        <ol className={styles.stack} aria-live="polite" aria-relevant="additions">
          {children}
        </ol>
      );
    }
    
    components/ui/Toast.module.css css
    .stack {
      position: fixed;
      right: var(--space-4);
      bottom: var(--space-4);
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      margin: 0;
      padding: 0;
      list-style: none;
      z-index: 1000;
      pointer-events: none;
      max-width: min(420px, calc(100vw - var(--space-6)));
    }
    
    .toast {
      pointer-events: auto;
      position: relative;
      display: grid;
      grid-template-columns: 4px 1fr auto;
      align-items: stretch;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      box-shadow: var(--elev-raised);
      overflow: hidden;
      animation: slideIn var(--motion-fast) var(--ease-standard);
    }
    
    .accent {
      background: var(--accent);
    }
    
    .accentSuccess {
      background: var(--success);
    }
    
    .accentWarn {
      background: var(--warn);
    }
    
    .accentError {
      background: var(--danger);
    }
    
    .body {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      padding: var(--space-3) var(--space-3) var(--space-3) var(--space-3);
      min-width: 0;
    }
    
    .title {
      font-size: var(--text-sm);
      font-weight: 600;
      line-height: var(--leading-tight);
      color: var(--fg);
    }
    
    .description {
      font-size: var(--text-xs);
      line-height: var(--leading-body);
      color: var(--muted);
      white-space: pre-wrap;
      word-break: break-word;
    }
    
    .close {
      align-self: flex-start;
      background: transparent;
      border: 0;
      padding: var(--space-2);
      margin: var(--space-1);
      color: var(--muted);
      border-radius: var(--radius-sm);
      cursor: pointer;
      line-height: 0;
    }
    
    .close:hover {
      background: color-mix(in oklab, var(--fg), transparent 92%);
      color: var(--fg);
    }
    
    .close:focus-visible {
      outline: none;
      box-shadow: var(--focus-ring);
    }
    
    @keyframes slideIn {
      from {
        opacity: 0;
        transform: translateX(16px);
      }
      to {
        opacity: 1;
        transform: translateX(0);
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      .toast {
        animation: none;
      }
    }
    

    Holen

    # MCP (Claude Code / Cursor)
    get_component({ name: "toast" })
    
    # Registry direkt
    https://www.ekklesi.tools/design/registry/components/toast.json