Zum Inhalt springen
UI Kit
Accent

Toast Benachrichtigungen für React

Kurze Meldungen nach einer Aktion, gestapelt und beim Hovern aufgefächert. Zeiten pausieren beim Lesen, Fehler bleiben länger.

Eine von 9 Components in Overlays

2 Variants

Rückmeldung, die kommt und geht

Ein Toast bestätigt, dass eine Aktion geklappt hat, oder erklärt, warum nicht, ohne die Person aufzuhalten. Dieser meldet jede Nachricht über eine Live-Region, Screenreader hören sie also, ohne dass der Fokus springt, und sein Timer pausiert, solange der Zeiger darauf ruht oder der Fokus darin liegt.

„Varianten“ zeigt die Töne für Erfolg, Info, Warnung und Fehler, jeweils mit Icon und klaren Worten, dazu eine Aktion wie Rückgängig. Der Live-Stapel lässt mehrere Toasts in einer Ecke stapeln und beim Hover auffächern, der neueste oben. Fehler bleiben länger stehen als Erfolge, weil man sie meist lesen muss.

Schreib, was passiert ist, in der Vergangenheit, Gespeichert, Einladung verschickt. Biete Rückgängig an, statt vor einer umkehrbaren Aktion nachzufragen. Wichtige Informationen gehören nie nur in einen Toast, er verschwindet.

Variants

Varianten

Erfolg, Fehler, Warnung, Info, Laden und eine Aktion. Immer Icon und Worte, nie nur Farbe.

Live-Stapel

Ein paar Hinweise auslösen. Sie stapeln sich unten rechts, fächern beim Hovern auf und schließen von selbst.

Details

Was Hinweis mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Overlays
Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/toast.tsx
Bringt mit
utilskiticonloading-buttonpopover
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 2 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle ToastViewProps aus components/ui/toast.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ToastData, 'id' | 'duration'> an.

  • onDismissoptional
    () => void

Eingebaute Texte

Diese Texte schreibt Hinweis selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 2 Texte anzeigen
closeEnglisch DismissDeutsch Ausblenden
regionEnglisch NotificationsDeutsch Benachrichtigungen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle variant.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 299 Zeilen
components/ui/toast.tsx
'use client';

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Spinner } from '@/components/ui/loading-button';
import { useMounted } from '@/components/ui/popover';

const TEXTS = { en: { close: 'Dismiss', region: 'Notifications' }, de: { close: 'Ausblenden', region: 'Benachrichtigungen' } };

/* The icon pops in with a small overshoot, the timer line runs down and pauses with the stack */
const CSS =
  '.kitfx-toast-icon{animation:kitfx-toast-icon .5s cubic-bezier(.34,1.56,.64,1) .08s both}@keyframes kitfx-toast-icon{from{opacity:0;transform:scale(.4) rotate(-20deg)}}' +
  '.kitfx-toast-timer{transform-origin:left;animation:kitfx-toast-timer var(--kitfx-toast-d) linear forwards}@keyframes kitfx-toast-timer{to{transform:scaleX(0)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-toast-icon{animation:none}.kitfx-toast-timer{display:none}}';

/* Toasts that are on their way out: they stay in the list for their exit animation */
const EXIT_MS = 260;
let leaving = new Set<string>();

export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading';

export interface ToastData {
  id: string;
  title: React.ReactNode;
  description?: React.ReactNode;
  variant?: ToastVariant;
  /** One action, e.g. Undo */
  action?: { label: string; onClick: () => void };
  /** ms until it hides, Infinity keeps it. Errors stay longer by default. */
  duration?: number;
}

/* ------------------------------------------------------------------ */
/* Store                                                               */
/* ------------------------------------------------------------------ */

let items: ToastData[] = [];
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
let counter = 0;

function add(data: Omit<ToastData, 'id'> & { id?: string }) {
  const id = data.id ?? `t${++counter}`;
  if (leaving.has(id)) leaving = new Set([...leaving].filter((x) => x !== id));
  const exists = items.some((x) => x.id === id);
  items = exists ? items.map((x) => (x.id === id ? { ...x, ...data, id } : x)) : [{ ...data, id }, ...items];
  emit();
  return id;
}

type Input = Omit<ToastData, 'id' | 'title' | 'variant'> & { id?: string };

/**
 * Show a toast from anywhere. Needs one <Toaster /> on the page.
 *   toast('Saved')  toast.success('Invoice sent', { description: '…' })
 *   toast.promise(save(), { loading: 'Saving', success: 'Saved', error: 'Could not save' })
 */
export const toast = Object.assign((title: React.ReactNode, opts?: Input) => add({ title, ...opts }), {
  success: (title: React.ReactNode, opts?: Input) => add({ title, variant: 'success', ...opts }),
  error: (title: React.ReactNode, opts?: Input) => add({ title, variant: 'error', ...opts }),
  warning: (title: React.ReactNode, opts?: Input) => add({ title, variant: 'warning', ...opts }),
  info: (title: React.ReactNode, opts?: Input) => add({ title, variant: 'info', ...opts }),
  loading: (title: React.ReactNode, opts?: Input) => add({ title, variant: 'loading', duration: Infinity, ...opts }),
  dismiss: (id?: string) => {
    const ids = id ? [id] : items.map((x) => x.id);
    if (typeof window === 'undefined' || prefersReducedMotion()) {
      items = items.filter((x) => !ids.includes(x.id));
      emit();
      return;
    }
    const fresh = ids.filter((x) => !leaving.has(x) && items.some((i) => i.id === x));
    if (!fresh.length) return;
    leaving = new Set([...leaving, ...fresh]);
    items = [...items];
    emit();
    window.setTimeout(() => {
      items = items.filter((x) => !fresh.includes(x.id));
      leaving = new Set([...leaving].filter((x) => !fresh.includes(x)));
      emit();
    }, EXIT_MS);
  },
  promise: <T,>(p: Promise<T>, msgs: { loading: React.ReactNode; success: React.ReactNode; error: React.ReactNode }) => {
    const id = add({ title: msgs.loading, variant: 'loading', duration: Infinity });
    p.then(
      () => add({ id, title: msgs.success, variant: 'success', duration: 4000 }),
      () => add({ id, title: msgs.error, variant: 'error', duration: 6000 }),
    );
    return p;
  },
});

const subscribe = (l: () => void) => {
  listeners.add(l);
  return () => listeners.delete(l);
};
const snapshot = () => items;
const empty: ToastData[] = [];

/* ------------------------------------------------------------------ */
/* View                                                                */
/* ------------------------------------------------------------------ */

/* Status colors come from the kit tokens. In light mode the icon takes a little
   foreground, so the pale warning yellow still reaches 3:1 on the popover. */
const ICON: Record<Exclude<ToastVariant, 'default' | 'loading'>, { icon: IconName; color: string }> = {
  success: { icon: 'check-circle', color: 'text-success' },
  error: { icon: 'x-circle', color: 'text-destructive' },
  warning: { icon: 'alert', color: 'text-[color-mix(in_oklab,var(--warning)_78%,var(--foreground))] dark:text-warning' },
  info: { icon: 'info', color: 'text-info' },
};

export interface ToastViewProps extends Omit<ToastData, 'id' | 'duration'> {
  onDismiss?: () => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

/** The visible toast. Rendered by the Toaster, or alone to show the look. */
export function ToastView({ title, description, variant = 'default', action, onDismiss, labels, className }: ToastViewProps) {
  const t = useTexts(TEXTS, labels);
  const icon = variant !== 'default' && variant !== 'loading' ? ICON[variant] : null;
  return (
    <div
      role={variant === 'error' ? 'alert' : 'status'}
      className={cn(
        'group/toast pointer-events-auto relative flex w-full items-start gap-3 rounded-xl border border-border bg-popover p-4 pe-11 text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]',
        className,
      )}
    >
      <style href="kitfx-toast" precedence="default">
        {CSS}
      </style>
      {variant === 'loading' && <Spinner size={18} className="mt-px text-muted-foreground" />}
      {icon && <Icon key={variant} name={icon.icon} size={18} className={cn('kitfx-toast-icon mt-px', icon.color)} />}
      <div className="min-w-0 flex-1">
        <p className="text-sm font-medium leading-5 text-foreground">{title}</p>
        {description && <p className="mt-0.5 text-pretty text-[13px] leading-5 text-muted-foreground">{description}</p>}
        {action && (
          <button
            type="button"
            onClick={() => {
              action.onClick();
              onDismiss?.();
            }}
            className="mt-2.5 inline-flex h-7 items-center rounded-md border border-border-strong bg-background px-2.5 text-xs font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent"
          >
            {action.label}
          </button>
        )}
      </div>
      {onDismiss && (
        <button
          type="button"
          onClick={onDismiss}
          aria-label={t.close}
          className="group/close absolute end-2.5 top-2.5 grid size-7 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground"
        >
          <Icon name="x" size={14} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/close:rotate-90 motion-reduce:transition-none" />
        </button>
      )}
    </div>
  );
}

function Timed({ item, paused, onHeight, from, className, style, children }: { item: ToastData; paused: boolean; onHeight: (h: number) => void; from: 'top' | 'bottom'; className?: string; style?: React.CSSProperties; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const liRef = useRef<HTMLLIElement>(null);
  const total = item.duration ?? (item.variant === 'error' ? 7000 : 4500);
  const left = useRef(total);
  // Arrives from its edge with a little spring
  useEffect(() => {
    const el = liRef.current;
    if (!el || prefersReducedMotion()) return;
    el.animate([{ opacity: 0, transform: `translateY(${from === 'top' ? -24 : 24}px) scale(0.94)` }, { opacity: 1, transform: 'none' }], { duration: 480, easing: 'cubic-bezier(0.34, 1.3, 0.64, 1)' });
  }, [from]);
  useEffect(() => {
    left.current = item.duration ?? (item.variant === 'error' ? 7000 : 4500);
  }, [item.duration, item.variant, item.title]);
  useEffect(() => {
    if (paused || !Number.isFinite(left.current)) return;
    const start = Date.now();
    const id = window.setTimeout(() => toast.dismiss(item.id), left.current);
    return () => {
      window.clearTimeout(id);
      left.current -= Date.now() - start;
    };
  }, [paused, item.id, item.variant, item.title]);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(() => onHeight(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, [onHeight]);
  return (
    <li ref={liRef} className={className} style={style}>
      <div ref={ref} className="relative">
        {children}
        {Number.isFinite(total) && (
          <span aria-hidden className="pointer-events-none absolute inset-x-4 bottom-0 h-px overflow-hidden rounded-full">
            <span
              key={`${item.variant}-${String(item.title)}`}
              className="kitfx-toast-timer block h-full bg-[color-mix(in_oklab,var(--foreground)_28%,transparent)]"
              style={{ '--kitfx-toast-d': `${total}ms`, animationPlayState: paused ? 'paused' : 'running' } as React.CSSProperties}
            />
          </span>
        )}
      </div>
    </li>
  );
}

export interface ToasterProps {
  position?: 'bottom-right' | 'bottom-center' | 'top-right' | 'top-center';
  /** How many stay visible in the stack */
  visible?: number;
  labels?: Partial<(typeof TEXTS)['en']>;
}

/**
 * Renders the toasts as a stack that fans out on hover or focus. One per page.
 * Timers pause while the pointer or focus is on the stack.
 */
export function Toaster({ position = 'bottom-right', visible = 3, labels }: ToasterProps) {
  const t = useTexts(TEXTS, labels);
  const list = useSyncExternalStore(subscribe, snapshot, () => empty);
  const mounted = useMounted();
  const [expanded, setExpanded] = useState(false);
  const [heights, setHeights] = useState<Record<string, number>>({});
  const setHeight = useCallback((id: string, h: number) => setHeights((cur) => (cur[id] === h ? cur : { ...cur, [id]: h })), []);
  const top = position.startsWith('top');
  const gap = 10;

  useEffect(() => {
    if (!list.length) setExpanded(false);
  }, [list.length]);

  if (!mounted) return null;
  // Toasts on their way out keep their place until they are gone, the rest stacks behind the front one
  const live = list.filter((x) => !leaving.has(x.id));
  const frontHeight = heights[live[0]?.id ?? list[0]?.id] ?? 72;
  const side = position.endsWith('right') ? 1 : 0;
  let offset = 0;
  let rank = 0;
  return createPortal(
    <section
      aria-label={t.region}
      className={cn(
        'pointer-events-none fixed z-[60] flex w-full flex-col px-4 sm:w-[24rem]',
        top ? 'top-4' : 'bottom-4',
        position.endsWith('right') ? 'sm:end-4' : 'sm:start-1/2 sm:-translate-x-1/2',
        'max-sm:inset-x-0',
      )}
      onPointerEnter={() => setExpanded(true)}
      onPointerLeave={() => setExpanded(false)}
      onFocus={() => setExpanded(true)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setExpanded(false)}
    >
      <ol className="relative" style={{ height: expanded ? live.slice(0, visible).reduce((s, x) => s + (heights[x.id] ?? 72) + gap, -gap) : frontHeight }}>
        {list.map((item) => {
          const out = leaving.has(item.id);
          const i = out ? Math.max(0, rank - 1) : rank++;
          const h = heights[item.id] ?? 72;
          const y = expanded ? (out ? Math.max(0, offset - h - gap) : offset) : i * 10;
          if (!out) offset += h + gap;
          const hidden = i >= visible;
          return (
            <Timed
              key={item.id}
              item={item}
              from={top ? 'top' : 'bottom'}
              paused={expanded}
              onHeight={(hh) => setHeight(item.id, hh)}
              className="absolute inset-x-0 transition-[translate,scale,opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none"
                style={{
                  [top ? 'top' : 'bottom']: 0,
                  zIndex: out ? list.length + 1 : list.length - rank,
                  translate: `0 ${top ? y : -y}px`,
                  scale: expanded || out ? '1' : String(1 - i * 0.05),
                  opacity: hidden || out ? 0 : 1,
                  transform: out ? (side ? 'translateX(calc(100% + 1rem))' : `translateY(${top ? -16 : 16}px) scale(0.96)`) : undefined,
                  pointerEvents: hidden ? 'none' : undefined,
                  transformOrigin: top ? 'bottom center' : 'top center',
                  height: !expanded && i > 0 ? frontHeight : undefined,
                  overflow: !expanded && i > 0 ? 'hidden' : undefined,
                  borderRadius: 12,
                }}
            >
              <ToastView {...item} labels={labels} onDismiss={() => toast.dismiss(item.id)} />
            </Timed>
          );
        })}
      </ol>
    </section>,
    document.body,
  );
}

Häufige Fragen

Wie installiere ich Hinweis?

Mit aiw add @ai-website/toast. Der Befehl legt die Datei components/ui/toast.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, loading-button und popover. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Hinweis?

2 Varianten, Varianten und Live-Stapel.

Kann ich die Texte von Hinweis übersetzen?

Ja. Hinweis bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Ausblenden“ und „Benachrichtigungen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Hinweis einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Hinweis mit aiw_components und installieren sie mit aiw_add @ai-website/toast.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert