Skip to content
Component installs need the registry setup
VegaStack Design

Toast

Brief, non-blocking notifications — a stacking Base UI Toast surface with typed icons, actions and promise toasts.

Status
stable
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 toast

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/toast

Run 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

Toaster
Toast — data-slot="toast"
ToastAction — data-slot="toast-action"
ToastArrow — data-slot="toast-arrow"
ToastClose — data-slot="toast-close"
ToastContent — data-slot="toast-content"
ToastDescription — data-slot="toast-description"
ToastPortal — data-slot="toast-portal"
ToastPositioner — data-slot="toast-positioner"
ToastProvider
ToastTitle — data-slot="toast-title"
ToastViewport — data-slot="toast-viewport"

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.

PropTypeDefaultDescription
anchor"bottom" | "none" | "top" | null—
PropTypeDefaultDescription
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" with aria-live="polite" and aria-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 invisible after:-inset-2 hit 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-60 band, one above the single z-50 overlay 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.tsx measures both halves, the computed bands and a real elementFromPoint hit test, rather than assuming either.
  • The toast root takes the single global 2px :focus-visible outline — 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 hidden role="alert" clone in the viewport and marks the visible root role="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. Here tabIndex follows aria-hidden on the root and on the action and close buttons, so the toast re-enters the tab order the moment the engine drops aria-hidden, which it does as soon as the viewport takes focus (press F6). 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.
ContractStates tested
Behaviourdefault, open, closed, expanded, swiping, limited, promise
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation
Visualdefault, success, info, warning, error, loading, hover, focus

Do / Don't

Do
Mount exactly one `Toaster` at the app root — VegaStackProvider already does — and fire into the module-scope toast manager from anywhere, including outside React.
Don't
Mount a second `Toaster`. Each one renders its own live region and its own fixed viewport, so two of them announce every toast twice and overlap in the same corner.

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/50 halo goes; dropping outline-none lets the single 2px :focus-visible outline base.css owns through.
  • COL-12 — ToastIcon paints each type in its own status family: success, info and warning gain an ink where upstream left them inheriting the body colour, and error moves from the destructive fill to destructive-text. The -text role is the one that passes AA as ink on a popover surface.
  • OVL-13 — ToastPortal reads useInternalThemeScope() and wraps its children in a display: contents element carrying that class.
  • A11Y-9 — tabIndex follows aria-hidden. The root's render reads aria-hidden off the props Base UI hands it — never recomputing priority === "high" && !focused — and publishes it to ToastAction and ToastClose, so nothing inside an aria-hidden toast is tabbable. inert is 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: ToastIcon renders a distinct lucide glyph per type, and the COL-12 hunk above only colours glyphs that are already there. toast.test.tsx asserts the icon, so a future upstream that drops it fails here.
  • A11Y-3, A11Y-4 — NO HUNK. Base UI's Toast.Viewport is the mounted-for-life polite region this policy asks for — role="region", aria-live="polite", rendered by Toaster whether or not a toast exists — and per-toast urgency is the manager's priority: "high". Adding a second region would announce every toast twice. toast.test.tsx asserts the region's shape.
  • OVL-15 — three additive extensions. Toaster/ToastViewport take a position prop (six logical corners; --toast-dir carries the growth sign into every vertical term, so one set of transforms serves a top- and a bottom-pinned stack, and the root's anchor variant flips the parts that are not arithmetic). ToastPositioner and ToastArrow wrap the two Base UI parts upstream wraps neither of; the corner-stack recipe moves into the anchor variant so a root inside a positioner emits none of it, which Toast resolves from context rather than from a prop — an anchored composition needs no className of its own. A toast carrying data.render owns its body, and data.actions draws up to two action buttons with the type icon kept. The viewport and the positioner take a z-60 band, one above the shared z-50 overlay band — OVL-2 is untouched, and z-60 is plain Tailwind, so no --z-* token returns.
  • COL-23 — ToastDescription gains first:text-popover-foreground. Base UI renders Toast.Title as null when a toast has no title, so upstream's flat text-muted-foreground painted 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 what first: tests; a description under a title stays muted. Hierarchy, not contrast: muted-foreground on popover passes AA on its own.
  • ICO-8 — ToastIcon's loading mark is LoaderIcon (lucide loader) in place of upstream's Loader2Icon, which is an alias of LoaderCircle. One loader shape across the system: Spinner and this icon are the same object spinning. animate-spin and aria-hidden are upstream's.
  • OVL-17 — one toast store. ToastProvider marks its subtree, and a Toaster on the module toast manager renders only its viewport under an existing provider instead of mounting a second one. Under VegaStackProvider, which mounts ToastProvider toastManager={toast}, toast() and useToastManager() feed the same queue and there is one viewport. A Toaster given its own manager still brings its own provider.
  • DOC-2 — cn is imported from @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header; no decision of their own.

On this page