Toasts report on something that already happened or is happening elsewhere. The input is a function, toast(), callable from anywhere: an action body, a store, an effect. The output is yours: a Toast.Viewport rendered once maps the queue to parts. The queue is the library's first state that is urgent by design: React holds every transition while an async action is pending, so a toast pushed as a transition would appear only after the action it reports on. Toast spends its Async React budget elsewhere: a toast that follows a promise, an Undo that is an ordinary action prop with data-pending, a closeAction that knows whether the Undo window was used, and a Suspense guard so a toast whose content suspends does not blank the page.
"use client";
import { useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { toast } from "@funstack/ui/toast";
import { fakeSave } from "./fake-api.js";
// One promise feeds both the menu item and the toast: `toast.promise` shows "Archiving…" at once
// (data-pending on the toast), swaps to the success or error toast when the fake server call
// settles, and returns a promise that settles with it, so the item stays pending exactly as long
// and the menu closes when it is done. The success toast's Undo is an action prop: pending while
// it runs, then the toast closes. With `error` given, a failure is the toast's to show: the
// returned promise resolves and nothing reaches an Error Boundary.
export function ToastArchive() {
const [fail, setFail] = useState(false);
return (
<div className="demo-stack">
<div className="demo-row">
<span>Quarterly report.pdf</span>
<Menu.Root>
<Menu.Trigger className="demo-button" aria-label="Actions for Quarterly report.pdf">
{({ pending }) => (pending ? <span className="demo-spinner" aria-hidden /> : "⋯")}
</Menu.Trigger>
<Menu.Popup className="demo-menu">
<Menu.Item
action={() =>
toast.promise(fakeSave("the archive", { ms: 1500, fail }), {
pending: { title: "Archiving…" },
success: {
title: "Archived",
description: "Quarterly report.pdf",
type: "success",
action: {
label: "Undo",
action: () => fakeSave("the undo", { ms: 800 }),
},
},
error: (e) => ({
title: "Could not archive",
description: e instanceof Error ? e.message : String(e),
type: "error",
priority: "high",
}),
})
}
>
{({ pending }) => (pending ? "Archiving…" : "Archive")}
</Menu.Item>
<Menu.Item action={() => toast({ title: "Link copied" })}>Copy link</Menu.Item>
</Menu.Popup>
</Menu.Root>
</div>
<label className="demo-row">
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
Make Archive fail
</label>
</div>
);
}/* Toast: the Viewport is a <section popover="manual">, shown in the top layer while it has toasts
(`:popover-open`, `data-paused`). Roots carry `data-pending`, `data-ending` and `data-type`.
Enter with @starting-style, exit on [data-ending]: the library keeps the toast until its
transitions finish. */
.demo-toasts {
position: fixed;
inset: auto 1rem 1rem auto;
margin: 0;
padding: 0;
border: 0;
inline-size: min(22rem, calc(100vw - 2rem));
overflow: visible;
background: transparent;
color: var(--color-text);
/* The region's box never blocks the page; the toasts take pointer events. */
pointer-events: none;
}
.demo-toast-slot {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 200ms;
}
/* Collapse the gap a leaving toast leaves behind. */
.demo-toast-slot:has([data-ending]) {
grid-template-rows: 0fr;
}
.demo-toast {
display: flex;
align-items: start;
gap: 0.5rem;
min-block-size: 0;
overflow: hidden;
margin-block-start: 0.5rem;
padding: 0.75rem 0.75rem 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
pointer-events: auto;
transition:
opacity 200ms,
translate 200ms;
}
@starting-style {
.demo-toast {
opacity: 0;
translate: 0 1rem;
}
}
.demo-toast[data-ending] {
opacity: 0;
translate: 0 1rem;
}
.demo-toast[data-pending] {
cursor: progress;
}
.demo-toast[data-pending] .demo-toast-title::after {
content: " ⏳";
}
.demo-toast[data-type="success"] {
border-inline-start: 4px solid #16a34a;
}
.demo-toast[data-type="error"] {
border-inline-start: 4px solid #dc2626;
}
.demo-toast:focus-visible {
outline: 2px solid var(--color-accent, #4f46e5);
outline-offset: 2px;
}
.demo-toast-text {
flex: 1;
}
.demo-toast-title {
font-weight: 600;
}
.demo-toast-description {
margin: 0.15rem 0 0;
font-size: 0.875em;
opacity: 0.8;
}
.demo-toast-action[data-pending] {
cursor: progress;
opacity: 0.7;
}
.demo-toast-close {
border: 0;
padding: 0 0.4em;
background: none;
color: inherit;
font: inherit;
font-size: 1.2em;
line-height: 1.4;
cursor: pointer;
}
.demo-toast-queued {
margin: 0.25rem 0 0;
text-align: end;
font-size: 0.8em;
opacity: 0.8;
}
.demo-toast-list {
margin: 0;
padding: 0;
list-style: none;
}
.demo-toast-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-inline-size: 20rem;
padding: 0.25rem 0;
}
@media (prefers-reduced-motion: reduce) {
.demo-toast,
.demo-toast-slot {
transition: none;
}
}import { Toast, toast } from "@funstack/ui/toast";
// Once, near the root of the app (from a Client Component: children is a function).
<Toast.Viewport> {/* section[popover=manual][aria-label="Notifications (F8)"] */}
{({ toasts }) =>
toasts.map((t) => (
<Toast.Root key={t.id} toast={t}> {/* div[role=dialog|alertdialog][tabindex=0] */}
<Toast.Title /> {/* div: t.title */}
<Toast.Description /> {/* p: t.description, nothing when absent */}
<Toast.Action /> {/* button: t.action, nothing when absent */}
<Toast.Close aria-label="Close">×</Toast.Close>
</Toast.Root>
))
}
</Toast.Viewport>
// Anywhere on the client:
toast({ title: "Link copied" });This page's Viewport, which every example on it feeds:
"use client";
import { Toast } from "@funstack/ui/toast";
import type { ToastItem } from "@funstack/ui/toast";
// The toast region, rendered once (here: once on this page; in an app, near the root). It is a
// `<section popover="manual">` in the top layer, shown while it has toasts, and moves into an
// open modal Dialog by itself. The Viewport's children map the queue to parts.
function renderToast(t: ToastItem) {
return (
<div className="demo-toast-slot" key={t.id}>
<Toast.Root toast={t} className="demo-toast">
<div className="demo-toast-text">
<Toast.Title className="demo-toast-title" />
<Toast.Description className="demo-toast-description" />
</div>
<Toast.Action className="demo-button demo-toast-action">
{({ pending }) => (pending ? "Undoing…" : t.action?.label)}
</Toast.Action>
<Toast.Close className="demo-toast-close" aria-label="Close">
×
</Toast.Close>
</Toast.Root>
</div>
);
}
export function ToastNotifications() {
return (
<Toast.Viewport className="demo-toasts">
{({ toasts, queued }) => (
<>
{toasts.map(renderToast)}
{queued > 0 && <p className="demo-toast-queued">+{queued} more</p>}
</>
)}
</Toast.Viewport>
);
}toast is bound to a module-level default manager; a Viewport without manager renders it. A second Viewport on the same manager warns and renders nothing. createToastManager() makes an isolated queue (a second React root, a test) for <Toast.Viewport manager={m}>.dialogs (alertdialog for priority: "high") named by their Title and described by their Description. The region is wherever the Viewport is rendered; its only move (into a modal dialog) is the library's. On the client it sits inside two display: contents wrappers: an anchor where the Viewport is rendered, and the element the library moves.div, not a heading: transient notifications stay out of heading navigation.closeAction runs).Every queue change (add, update, dismiss, the drop after an exit animation) is a normal, urgent update. toast() dispatches from a microtask, so a call inside a startTransition scope, the sync prefix of an action included, escapes the scope and shows at once; calls keep their order. Everything a toast drives is a transition as usual: its Action's action and its closeAction.
"use client";
import { useTransition } from "react";
import { toast } from "@funstack/ui/toast";
import { sleep } from "./fake-api.js";
// React holds every transition update while an async action is pending anywhere in the app. A
// toast pushed as a transition would therefore appear only after the action it reports on. The
// queue is urgent instead: `toast()` dispatches from a microtask, so the "Saving…" toast shows at
// once, from inside the action's own transition, while the button is still pending.
export function ToastUrgent() {
const [pending, startTransition] = useTransition();
return (
<div className="demo-row">
<button
type="button"
className="demo-button"
data-pending={pending ? "" : undefined}
onClick={() =>
startTransition(async () => {
const id = toast({ title: "Saving…", duration: null });
await sleep(2000);
toast.update(id, { title: "Saved", type: "success", duration: 3000 });
})
}
>
{pending ? "Saving (2 s)…" : "Save"}
</button>
<button
type="button"
className="demo-button"
onClick={() => {
for (let i = 1; i <= 5; i++) toast({ title: `Message ${i} of 5` });
}}
>
Burst of five
</button>
</div>
);
}limit (default 3) toasts render at once; later ones wait in order and show as earlier ones leave. Nothing is dropped, so an Undo is never lost to a burst. state.queued counts the waiting ones. A toast() with an existing id updates that toast in place ("one toast per key").
toast.promise(promise, { pending, success, error }) shows pending at once with data-pending and no timer, and replaces it with success or error (options, or functions of the value / the reason) when the promise settles: same toast, same element, announced again. It returns a promise that settles with the input in the same tick, so action={() => toast.promise(save(), …)} keeps the part that started the work pending for exactly as long.
| The promise | Toast | Returned promise |
|---|---|---|
fulfils, success given | replaced by success | resolves with the value |
fulfils, no success | dismissed (reason: "dismiss") | resolves with the value |
rejects, error given | replaced by error | resolves with undefined: the toast is the handler |
rejects, no error | dismissed | rejects with the reason (the action prop's Error Boundary gets it) |
a success / error function throws | dismissed (never left pending) | rejects with what the function threw |
Toast.Action is a pure-trigger action part, like Menu.Item. While its action runs the Action and the Root carry data-pending and the toast's timer is paused; when it resolves the toast closes with reason: "activate" (unless closeOnSettle={false}). A rejection keeps the toast and reaches the nearest Error Boundary above the Viewport: put one around the list.
A toast's closeAction runs once when it leaves, in the Viewport's transition, after the toast is already gone: "activate" (its Action settled), "dismiss" (Close, Escape, toast.dismiss(), a promise toast with no message for its outcome) or "timeout". That is the "do later, unless undone" pattern:
"use client";
import { startTransition, useState } from "react";
import { toast } from "@funstack/ui/toast";
import { sleep } from "./fake-api.js";
const initial = ["Groceries", "Dentist", "Call Ada"];
// "Do later, unless undone": the item leaves the list at once (local state), and the toast is the
// grace period. `closeAction` runs when the toast leaves, with the reason: "activate" when Undo
// was used, otherwise ("timeout", or "dismiss" through Close, Escape or toast.dismiss()) the
// deletion is committed. Hover or focus the toast to pause its 6 second timer.
export function ToastUndoWindow() {
const [items, setItems] = useState(initial);
const [log, setLog] = useState<readonly string[]>([]);
const note = (line: string) => startTransition(() => setLog((l) => [line, ...l].slice(0, 3)));
const remove = (name: string) => {
setItems((list) => list.filter((item) => item !== name));
toast({
title: `Deleted “${name}”`,
duration: 6000,
action: {
label: "Undo",
action: () => startTransition(() => setItems((list) => [...list, name])),
},
closeAction: async ({ reason }) => {
if (reason === "activate") {
note(`Restored ${name}`);
return;
}
await sleep(300); // the real delete request
note(`Deleted ${name} for good (${reason})`);
},
});
};
return (
<div className="demo-stack">
<ul className="demo-toast-list">
{items.map((name) => (
<li key={name}>
<span>{name}</span>
<button type="button" className="demo-button" onClick={() => remove(name)}>
Delete
</button>
</li>
))}
{items.length === 0 && (
<li>
<button type="button" className="demo-button" onClick={() => setItems(initial)}>
Reset list
</button>
</li>
)}
</ul>
<ul className="demo-readout">
{log.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
);
}toast(options) and the manager| Prop | Type | Default | Description |
|---|---|---|---|
toast(options) | (options: ToastOptions) => string | – | Adds a toast and returns its id (or updates the toast with options.id). Also toast.add. |
toast.promise | <T>(promise: PromiseLike<T>, options: ToastPromiseOptions<T>) => Promise<T | undefined> | – | { pending, success?, error?, id? }; success / error are options or functions of the value / reason. See the table above. |
toast.update | (id: string, options: Partial<ToastOptions>) => void | – | Changes a toast in place; undefined removes a field. Ignored for a closed toast; an unknown id warns in development. |
toast.dismiss | (id?: string) => void | – | Closes one toast, or all, with reason: "dismiss". Idempotent. |
createToastManager | () => ToastManager | – | An isolated queue with the same shape, for Toast.Viewport manager. |
On the server every method is a no-op with a development warning, and nothing is buffered (a module-level queue would be shared between requests). Return data from a Server Function and call toast() on the client.
ToastOptions| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | – | Rendered by Toast.Title; part of the default announcement. |
description | ReactNode | – | Rendered by Toast.Description; part of the default announcement. |
id | string | generated | An existing id updates that toast. |
type | string | – | Free-form ("success", "error"), rendered as data-type on the Root. |
priority | "normal" | "high" | "normal" | "high": announced with ariaNotify priority "high", Root role alertdialog. |
duration | number | null | Viewport `duration`; `null` with an `action` | Milliseconds until the toast closes with reason: "timeout"; null never. A toast with an action is persistent unless you set it; if you do, keep the action reachable elsewhere too (WCAG 2.2.1). |
action | { label: ReactNode; action: Action<[ToastActionDetails]> } | – | What Toast.Action renders and runs. details.id is the toast's id, so an inline action can update or dismiss its own toast. |
closeAction | Action<[ToastCloseDetails]> | – | Runs once when the toast leaves, in the Viewport's transition; details.reason is "activate", "dismiss" or "timeout". Not called when the Viewport unmounts. |
announce | (toast: ToastItem) => string | null | – | The text ariaNotify speaks for this toast; null for nothing. Wins over the Viewport's announce. |
data | unknown | – | Anything your Viewport children want to render. |
ToastItem, what state.toasts holds: the options plus id, pending (its promise is unsettled or its Action runs) and ending (closed, kept for its exit animation).
Toast.Viewport| Prop | Type | Default | Description |
|---|---|---|---|
manager | ToastManager | toast | The queue to render. |
limit | number | 3 | Toasts rendered at once; the rest wait in order. |
duration | number | null | 5000 | Default duration of a toast without an action. |
hotkey | string[] | null | ["F8"] | event.code values and modifier names ("altKey", "ctrlKey", "metaKey", "shiftKey") that focus the newest toast, with no other modifier held (Ctrl+F8 is not F8). Also names the region ("Notifications (Alt+T)"). null installs no listener. |
announce | (toast: ToastItem) => string | null | – | The announcement text for every toast (localisation). Default: the text of its Title and Description. |
aria-label | string | "Notifications (F8)" | The region's name; yours is used verbatim. |
className | string | ((state: ToastViewportState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToastViewportState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToastViewportState) => ReactNode) | – | Usually a function of the state that maps state.toasts to Toast.Roots. |
render | ReactElement | ((props, state: ToastViewportState) => ReactNode) | – | Replace the default <section> (element form) or take over rendering (function form). |
ToastViewportState: { toasts, queued, paused }. toasts is chronological (oldest first) and includes the ones animating out; paused is true while a viewport-wide pause holds.
Toast.Root| Prop | Type | Default | Description |
|---|---|---|---|
toast | ToastItem | – | Required. An item of state.toasts; key the Root (or its wrapper) on toast.id. |
fallback | ReactNode | null | Shown inside the Root while its children suspend (a skeleton). The timer and the announcement wait for the reveal; the Root is not data-pending meanwhile. Parts inside it (a visually hidden Toast.Title) name the toast but gate nothing. |
className | string | ((state: ToastRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToastRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToastRootState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ToastRootState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ToastRootState: { toast, pending, ending, type, priority }. Toast.Title (div) and Toast.Description (p) take the same function props against the Root's state; their children default to toast.title / toast.description, and the Description renders nothing when it has neither.
Toast.Action| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[ToastActionDetails]> | toast.action.action | Runs in this part's transition. The part renders nothing when neither it nor the toast has an action. |
closeOnSettle | boolean | true | Close the toast (reason: "activate") when the action resolves. |
className | string | ((state: ToastActionState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToastActionState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToastActionState) => ReactNode) | – | Default: toast.action.label. |
render | ReactElement | ((props, state: ToastActionState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
ToastActionState: { pending, toast }. Never disabled while pending: a second click is a second dispatch and the first resolve closes the toast.
Toast.Close| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ToastCloseState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToastCloseState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToastCloseState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ToastCloseState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
Closes the toast with reason: "dismiss", like Escape. Give it a name (text or aria-label); development warns otherwise. ToastCloseState: { toast }.
| Attribute | Part | Meaning |
|---|---|---|
data-pending | Root, Action | The toast's promise is unsettled, or its Action's action runs (not set while its content merely suspends) |
data-ending | Root | Closed, kept (and inert) until the animations the ending state started finish |
data-type | Root | The toast's type |
data-paused | Viewport | A viewport-wide timer pause holds; pause a progress bar with it |
data-empty | Viewport | No toasts rendered |
A toast's timer starts once it is visible (within limit), its content has rendered and it is not pending, and resumes with the remaining time after a pause. Timers pause while the pointer is over the region, focus is inside it, the document is hidden or the window is blurred (data-paused), and per toast while it is pending. Elapsing closes the toast with reason: "timeout". Pausing helps sighted users; the persistent default for action toasts and the hotkey are what give screen-reader users time to reach an Undo.
| Key | Where | Behaviour |
|---|---|---|
F8 (hotkey) | anywhere | Focus the newest toast; nothing (and no preventDefault) without toasts |
| Tab / Shift+Tab | region | Through each toast and its buttons in DOM order; nothing is trapped |
| ArrowDown / ArrowUp | a toast | Next / previous toast, no wrap |
| Escape | a toast | Dismiss it |
| Enter / Space | Action, Close | Native button activation |
hotkey.document.ariaNotify(), never a live region: after the toast's content has rendered, again when an update changes its text, with priority: "high" for high-priority toasts; several toasts in one tick become one announcement. Dismissals are silent. A queued toast is announced when it is added.The region is popover="manual": shown while it has toasts, it neither closes an open menu or popover nor is closed by one, and it has no light dismiss. A toast added while a later menu or popover covers the region raises it to the top again. A top-layer element outside an open modal <dialog> is inert, so while a Dialog is open as a modal the region moves into it (and back when it closes): an Undo stays clickable. The move keeps the toasts' React state (an Undo in flight stays pending), but it is a DOM move: the toasts' enter animation plays again and focus inside a toast is lost. Dialogs the library did not open are not detected.
Enter with @starting-style, exit on [data-ending]: the library keeps a closed toast mounted until the animations (transitions included) that [data-ending] started finish. Animations already running in the toast, such as a progress bar paused with data-paused, are not waited for. Toasts never use <ViewTransition> and no part has a transition prop: every Toast commit is urgent, and react-dom starts view transitions for transition commits only. Make the region's box click-through with pointer-events: none and give it back to the toasts; stack visually with flex-direction: column-reverse and :nth-last-child().
Toast content may suspend (title: <UserName id={id} />). Each Root wraps its children in its own Suspense boundary, so a suspending toast does not pull the Viewport into the nearest boundary above it (a toast mounts urgently, where that boundary would show its fallback). While the content suspends the Root shows its fallback prop (nothing by default), in the same frame as the toast() call; the timer does not run and nothing is announced. On the reveal the toast announces its Title and Description and its timer starts. An update whose new content suspends shows the fallback again and pauses the timer until the next reveal.
<Toast.Root key={t.id} toast={t} fallback={<ToastSkeleton />}>
<Toast.Title />
<Toast.Description />
</Toast.Root>Use fallback rather than your own <Suspense> inside the Root: the toast is still announced when your boundary reveals its Title and Description, but its timer starts at once, because the library cannot see your boundary's fallback.
<Toast.Viewport> takes a function child, so render it from a Client Component file that a Server Component layout includes. On the server it renders the empty region. toast() is client code: a Server Function returns data and the client calls toast() with it (in the action that called it, or in an effect on useActionState's result).
ariaNotify is in Chrome 141, Firefox 150 and Safari 27; where it is missing nothing is announced and nothing throws.