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