Toast
Brief, non-blocking notifications — a stacking Base UI Toast surface with typed icons, actions and promise toasts.
- Status
- Since
0.7.0- Accessibility pattern
- labelled live region
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Toast before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify toastInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/toastRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button.
Usage
Mount one <Toaster /> at the app root, then call the imperative manager from anywhere — including
outside the React tree.
import { Toaster, toast } from "@/components/ui/toast";
// app/layout.tsx
<body>
{children}
<Toaster />
</body>;
// anywhere
toast.add({
title: "Event created",
description: "Sunday, December 3 at 9:00 AM",
});Anatomy
Examples
Types
Set type to render a status icon. The built-in renderer recognises success, info, warning,
error and loading. Each type is a distinct glyph as well as a distinct ink, so the type is never
signalled by colour alone (A11Y-8), and each ink is the family's -text role, which is the one that
passes AA on the popover surface (COL-12). Pass priority: "high" to have a toast announced
urgently instead of politely.
Action
actionProps are button props. Clicking the action runs its handler and closes the toast — the
action has been taken, so there is no need to call toast.close yourself. Pass data: { keepOpen: true } for the rare action that should leave its toast up.
toast.add({
title: "Event created",
actionProps: { children: "Undo", onClick: () => undo(eventId) },
});Two actions. For a second action — "View" and "Move" after a create — pass data.actions, a
one- or two-item array of { label, onClick } — the toast manager's data is typed as
ToastData, so a third item is a type error. The buttons follow the text,
the type's icon stays, and each closes the toast once taken (data.keepOpen opts out). Never more
than two: a third choice belongs in the page, not in a notification. It replaces actionProps
when both are given.
toast.add({
type: "success",
title: "Created in General",
data: {
actions: [
{ label: "View", onClick: () => open(task) },
{ label: "Move", onClick: () => move(task) },
],
},
});One action, one toast. When the same action repeats while its toast is still up — a second "Task
done" before the first times out — rewrite the live toast with toast.update(id, …) instead of
stacking another; every toast.add in the app lands in the one queue, so a stack of copies is what
the user would see.
const id = toast.add({
title: "Task done",
actionProps: { children: "Undo", onClick: () => undo(taskId) },
});
// The same action again while it is showing:
toast.update(id, { title: "2 tasks done" });Promise
toast.promise drives one toast through loading, success and error rather than firing three. Each
of the three takes a string or a full options object, and success/error may be a function of the
resolved value or the rejection.
Position
Toaster pins the stack to any of six logical corners (OVL-15). Inline names are logical, so
bottom-end is bottom-right in an LTR document and bottom-left in an RTL one — one vocabulary, both
directions. The stack's growth direction and its swipe direction follow the corner, so a toast
always enters and leaves through the edge it is pinned to.
<Toaster position="top-end" />Anchored
A toast does not have to live in the corner stack. ToastPositioner positions one against an
element and ToastArrow points back at it, so a confirmation can appear beside the control that
caused it (OVL-15). Compose the parts yourself and pass the anchor through positionerProps on
add(), or — as here — straight to the positioner.
Custom
A toast carrying data.render draws its own body and keeps everything around it: the surface, the
stack, swipe-to-dismiss, Escape and the viewport's live region (OVL-15). Reach for it when a
notification needs an avatar or a thumbnail — not for a second action (data.actions), and not to
escape the toast contract.
toast.add({
data: { render: () => <YourNotification /> },
});Update
toast.update(id, …) rewrites a live toast in place and refreshes its auto-dismiss timer, so a
long-running job can report progress through ONE toast instead of stacking three. Start it with
timeout: 0 so it waits for the result rather than expiring mid-flight.
API Reference
The manager is a function, not a component, so it has no prop table. toast is the module-scope
manager the default <Toaster /> reads; createToastManager() makes an independent one, which is
what the previews on this page use so each fires into its own viewport.
toast.add(options): string
toast.close(id?: string): void
toast.update(id: string, updates | (prev) => updates): void
toast.promise(promise, { loading, success, error }): Promise<Value>add takes title, description, type, timeout (ms; 0 never auto-dismisses), priority
("low" | "high"), actionProps, data, onClose and onRemove, and returns the new toast's
id. close() with no id closes the frontmost toast.
| Prop | Type | Default | Description |
|---|---|---|---|
anchor | "bottom" | "none" | "top" | null | — |
| Prop | Type | Default | Description |
|---|---|---|---|
position | "bottom-center" | "bottom-end" | "bottom-start" | "top-center" | "top-end" | "top-start" | null | — |
ToastAction adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Toaster accepts everything Base UI's Toast.Provider accepts — toastManager, limit, timeout
and children — plus position (the six logical corners above, default bottom-end) and
swipeDirection, which defaults to the stack's own block direction plus both inline directions. ToastProvider, ToastPortal, ToastContent, ToastTitle, ToastDescription and
ToastClose add no props of their own; each accepts everything the Base UI part accepts.
useToastManager() returns the live toasts array plus the same add / close / update /
promise methods, for building your own list — that is how the Anchored example above composes
ToastPositioner around each toast. To fire a toast you need no hook: the module-scope toast
works anywhere, including outside React. Under VegaStackProvider the hook and toast() share one
queue — the provider carries the module manager, and the Toaster renders into it (OVL-17).
Accessibility
- The viewport is a
role="region"witharia-live="polite"andaria-label="Notifications", and<Toaster />renders it whether or not a toast exists — so the region is being observed before the first toast arrives (A11Y-3, A11Y-4). - Per-toast urgency is the manager's job:
priority: "high"announces urgently. There is no second live region, because a second one would announce every toast twice. - Every type renders its own lucide glyph, so the type survives a monochrome or colour-blind rendering (A11Y-8).
- The close button carries
aria-label="Close toast"and an invisibleafter:-inset-2hit area, so its touch target clears the minimum without growing the visible control. - The toast surface portals out of its subtree, so the portal re-applies the theme scope (OVL-13) and a scoped or dark surface survives the move.
- A toast fired while a modal is open renders ABOVE the scrim. The viewport sits in a
z-60band, one above the singlez-50overlay band every other surface shares (OVL-15). It has to be a band rather than DOM order: the viewport mounts with the provider, before any dialog exists, so under one shared band the later portal — the dialog — would always cover it.test/stacking.browser.test.tsxmeasures both halves, the computed bands and a realelementFromPointhit test, rather than assuming either. - The toast root takes the single global 2px
:focus-visibleoutline — no ring, no halo (FOC-1, FOC-6). - A
priority: "high"toast leaves the tab order while it is hidden from assistive technology (A11Y-9). Base UI announces a visually hiddenrole="alert"clone in the viewport and marks the visible rootrole="alertdialog" aria-hidden="true"for as long as the viewport is unfocused, while leaving that root and its buttons tabbable — a keyboard user would land on a control no screen reader can name. HeretabIndexfollowsaria-hiddenon the root and on the action and close buttons, so the toast re-enters the tab order the moment the engine dropsaria-hidden, which it does as soon as the viewport takes focus (pressF6). The mouse is untouched — an unfocused error toast's Retry button stays clickable — and every toast state, high priority included, is audited with no axe suppression at all.
| Contract | States tested |
|---|---|
| Behaviour | default, open, closed, expanded, swiping, limited, promise |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation |
| Visual | default, success, info, warning, error, loading, hover, focus |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/toast.patch. Every hunk:
- FOC-1, FOC-6 — the toast root's
outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50halo goes; droppingoutline-nonelets the single 2px:focus-visibleoutlinebase.cssowns through. - COL-12 —
ToastIconpaints each type in its own status family:success,infoandwarninggain an ink where upstream left them inheriting the body colour, anderrormoves from thedestructivefill todestructive-text. The-textrole is the one that passes AA as ink on apopoversurface. - OVL-13 —
ToastPortalreadsuseInternalThemeScope()and wraps its children in adisplay: contentselement carrying that class. - A11Y-9 —
tabIndexfollowsaria-hidden. The root'srenderreadsaria-hiddenoff the props Base UI hands it — never recomputingpriority === "high" && !focused— and publishes it toToastActionandToastClose, so nothing inside anaria-hiddentoast is tabbable.inertis deliberately not used: it would take the mouse with it. - A11Y-8 — NO HUNK. Upstream already signals type with a shape as well as a colour:
ToastIconrenders a distinct lucide glyph per type, and the COL-12 hunk above only colours glyphs that are already there.toast.test.tsxasserts the icon, so a future upstream that drops it fails here. - A11Y-3, A11Y-4 — NO HUNK. Base UI's
Toast.Viewportis the mounted-for-life polite region this policy asks for —role="region",aria-live="polite", rendered byToasterwhether or not a toast exists — and per-toast urgency is the manager'spriority: "high". Adding a second region would announce every toast twice.toast.test.tsxasserts the region's shape. - OVL-15 — three additive extensions.
Toaster/ToastViewporttake apositionprop (six logical corners;--toast-dircarries the growth sign into every vertical term, so one set of transforms serves a top- and a bottom-pinned stack, and the root'sanchorvariant flips the parts that are not arithmetic).ToastPositionerandToastArrowwrap the two Base UI parts upstream wraps neither of; the corner-stack recipe moves into theanchorvariant so a root inside a positioner emits none of it, whichToastresolves from context rather than from a prop — an anchored composition needs no className of its own. A toast carryingdata.renderowns its body, anddata.actionsdraws up to two action buttons with the type icon kept. The viewport and the positioner take az-60band, one above the sharedz-50overlay band — OVL-2 is untouched, andz-60is plain Tailwind, so no--z-*token returns. - COL-23 —
ToastDescriptiongainsfirst:text-popover-foreground. Base UI rendersToast.Titleasnullwhen a toast has no title, so upstream's flattext-muted-foregroundpainted a description-only toast's only line — its primary message — in the secondary ink. The description leads only when no title rendered before it, which is exactly whatfirst:tests; a description under a title stays muted. Hierarchy, not contrast:muted-foregroundonpopoverpasses AA on its own. - ICO-8 —
ToastIcon's loading mark isLoaderIcon(lucideloader) in place of upstream'sLoader2Icon, which is an alias ofLoaderCircle. One loader shape across the system:Spinnerand this icon are the same object spinning.animate-spinandaria-hiddenare upstream's. - OVL-17 — one toast store.
ToastProvidermarks its subtree, and aToasteron the moduletoastmanager renders only its viewport under an existing provider instead of mounting a second one. UnderVegaStackProvider, which mountsToastProvider toastManager={toast},toast()anduseToastManager()feed the same queue and there is one viewport. AToastergiven its own manager still brings its own provider. - DOC-2 —
cnis imported from@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header; no decision of their own.