Skip to content
UI Kit
Accent color

Toast notifications for React

Short notes after an action, stacked and fanned out on hover. Timers pause while you read, errors stay longer.

One of 9 components in Overlays

2 variants

Feedback that comes and goes

A toast confirms that an action worked or explains why it did not, without stopping the person. This one announces each message through a live region, so screen readers hear it without moving focus, and its timer pauses while the pointer rests on it or focus is inside.

Variants shows the tones for success, info, warning and error, each with an icon and clear words, plus an action such as Undo. Live stack lets several toasts pile up in a corner and fan out on hover, the newest on top. Errors stay longer than success messages, because they usually need reading.

Write what happened, in past tense, Saved, Invitation sent. Offer Undo instead of asking for confirmation before a reversible action. Never put the only copy of important information in a toast, it disappears.

Variants

Variants

Success, error, warning, info, loading and one action. Always an icon and words, never color alone.

Live stack

Fire a few toasts. They stack bottom right, fan out on hover and close on their own.

Details

What Toast brings along, read from the registry and the source.

Category
Overlays
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/toast.tsx
Brings along
utilskiticonloading-buttonpopover
npm packages
None
Languages
English and German, 2 texts of its own
Studio
Adjust in code

Props

The ToastViewProps interface from components/ui/toast.tsx, with the comments from the code. Also takes every prop of Omit<ToastData, 'id' | 'duration'>.

  • onDismissoptional
    () => void

Built-in texts

Toast writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 2 texts
closeEnglish DismissGerman Ausblenden
regionEnglish NotificationsGerman Benachrichtigungen

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role variant.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 299 lines
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,
  );
}

Frequent questions

How do I install Toast?

With aiw add @ai-website/toast. The command puts components/ui/toast.tsx into your project, the code is yours from then on. It brings along icon, loading-button and popover. It needs no further npm packages.

Which variants does Toast have?

2 variants, Variants and Live stack.

Can I translate the texts of Toast?

Yes. Toast carries 2 texts of its own in English and German, such as “Dismiss” and “Notifications”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Toast?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Toast with aiw_components and install it with aiw_add @ai-website/toast.

Often searched

Jump to

Copied to the clipboard