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
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.
Details
What Toast brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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
| Key | English | German |
|---|---|---|
| close | English Dismiss | German Ausblenden |
| region | English Notifications | German 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
'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.