Component · Navigation & Feedback
Toast
Overview
A short, transient message that reports the result of an action without interrupting what
the user is doing — "Draft saved", "Invite sent", "Upload failed". A Toast appears in a fixed
corner of the screen (bottom-right by default, or top-right), stacks with any others, and
(for non-error intents) dismisses itself after a few seconds.
Toast is the transient counterpart to Alert. If the message belongs in the flow of the page and should stay until the surrounding content changes, that is an Alert. If it needs a title, a body, and a form, that is a Dialog. Toast is for feedback that is safe to miss and safe to dismiss.
Toast is an imperative, queued notification: a provider renders the viewport, toast() adds a
message, timers auto-dismiss it (pausing while the user is interacting with it), and each message
is announced through a live region — see Accessibility.
Anatomy
ToastProvider (client root: the queue, the timers, the portalled viewport)
└─ ToastViewport <ol role="region" aria-label="Notifications" tabIndex=-1>
└─ Toast <li> — one notification
├─ (live region) role="status" | "alert", aria-atomic — holds:
│ ├─ intent icon decorative Lucide glyph (none for `default`)
│ ├─ (sr-only word) "Note:" / "Success:" / "Warning:" / "Error:"
│ ├─ ToastTitle the heading
│ ├─ ToastDescription optional supporting line
│ └─ ToastAction optional single button — running it also dismisses
└─ ToastClose the ✕ — always present
Callers never render Toast / ToastViewport directly. They mount one ToastProvider and
call the queue:
<ToastProvider duration={5000} max={3} position="bottom-right">
{app}
</ToastProvider>
// anywhere below it
const { toast, dismiss } = useToast();
const id = toast({
intent: "success",
title: "Invite sent",
description: "We emailed the invitation to your teammate.",
action: <ToastAction>Undo</ToastAction>,
});
dismiss(id); // one
dismiss(); // all
ToastProvider— holds the queue and every timer, and portals the viewport to<body>.duration(default5000ms) is the auto-dismiss delay fordefault/info/success;max(default3) is how many toasts are visible at once;position("bottom-right"default, or"top-right") is where the stack anchors — provider-level, never per toast.useToast()— returns{ toast, dismiss }. Throws if called outside aToastProvider. There is no module-leveltoast()singleton in Version 1.toast(options)— enqueues one toast and returns its id.options:intent(defaultdefault),title(required),description,action,duration(ms, orInfinityto persist — overrides the per-intent default).ToastAction— the one optional action, styled as a smalloutlinebutton. Running it runs itsonClickand then dismisses the toast.ToastClose— the✕, rendered automatically on every toast (aria-label="Dismiss"). A Toast is dismissible by contract; Alert is not.ToastViewport— therole="region"landmark that holds the toasts. Not the live region itself — each toast carries its own.
Live Example
Position
Intents
With an action
Stacking & the FIFO queue
Code Example
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
ToastAction,
ToastProvider,
useToast,
type ToastIntent,
type ToastPosition,
} from "@/components/ui/toast";
/**
* Live Toast example. `"use client"`, and the demo mounts its own
* `<ToastProvider>` (an app would mount one at the root). Toasts portal to a
* fixed viewport at a corner of the window — not inside this card — so this is
* the real placement, not a mock.
*
* - One button per intent. `default` / `info` / `success` auto-dismiss after
* 5s, `warning` after 8s; `destructive` stays until dismissed.
* - "Message archived" carries a `<ToastAction>` — running it (or the ✕)
* dismisses the toast.
* - "Notify ×5" fires five at once: three show, the rest wait in a FIFO queue
* and appear as the visible ones clear. Hover or focus the stack to pause
* every countdown.
* - The position toggle switches the provider between the two V1 placements —
* `bottom-right` (stacks upward) and `top-right` (stacks downward).
*/
const INTENTS: { intent: ToastIntent; label: string; title: string; description: string }[] = [
{
intent: "default",
label: "Neutral",
title: "Draft saved",
description: "Your changes are stored locally.",
},
{
intent: "info",
label: "Info",
title: "Sync scheduled",
description: "This workspace will sync in a few minutes.",
},
{
intent: "success",
label: "Success",
title: "Invite sent",
description: "We emailed the invitation to your teammate.",
},
{
intent: "warning",
label: "Warning",
title: "Storage almost full",
description: "You have used 92% of your plan's storage.",
},
{
intent: "destructive",
label: "Error",
title: "Upload failed",
description: "The connection dropped. Nothing was uploaded.",
},
];
function ToastDemo({
position,
onPositionChange,
}: {
position: ToastPosition;
onPositionChange: (next: ToastPosition) => void;
}) {
const { toast, dismiss } = useToast();
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Position
</h3>
<div className="flex flex-wrap gap-2">
{(["bottom-right", "top-right"] as const).map((value) => (
<Button
key={value}
variant={position === value ? "secondary" : "ghost"}
aria-pressed={position === value}
onClick={() => onPositionChange(value)}
>
{value}
</Button>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Intents
</h3>
<div className="flex flex-wrap gap-2">
{INTENTS.map(({ intent, label, title, description }) => (
<Button
key={intent}
variant="outline"
onClick={() => toast({ intent, title, description })}
>
{label}
</Button>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
With an action
</h3>
<Button
variant="outline"
onClick={() =>
toast({
intent: "success",
title: "Message archived",
action: <ToastAction>Undo</ToastAction>,
})
}
>
Archive message
</Button>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Stacking & the FIFO queue
</h3>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => {
for (let i = 1; i <= 5; i += 1) {
toast({
intent: "info",
title: `Notification ${i}`,
description: "Three show at once; the rest queue.",
});
}
}}
>
Notify ×5
</Button>
<Button variant="ghost" onClick={() => dismiss()}>
Dismiss all
</Button>
</div>
</section>
</div>
);
}
export function ToastShowcase() {
const [position, setPosition] =
React.useState<ToastPosition>("bottom-right");
return (
<ToastProvider position={position}>
<ToastDemo position={position} onPositionChange={setPosition} />
</ToastProvider>
);
}Variants
One axis: intent, five values — identical to Alert: same icon per intent,
same visually hidden intent word, same --{intent}-subtle surfaces.
| Intent | Surface | Border | Title + icon | Body | Icon | Hidden word |
|---|---|---|---|---|---|---|
default | --popover | --border | --popover-foreground | --popover-foreground | none | — |
info | --info-subtle | --info-subtle-border | --info-subtle-foreground | --foreground | Info | "Note:" |
success | --success-subtle | --success-subtle-border | --success-subtle-foreground | --foreground | CircleCheck | "Success:" |
warning | --warning-subtle | --warning-subtle-border | --warning-subtle-foreground | --foreground | TriangleAlert | "Warning:" |
destructive | --destructive-subtle | --destructive-subtle-border | --destructive-subtle-foreground | --foreground | OctagonAlert | "Error:" |
- The neutral toast uses
--popover, not Alert's--card: a Toast floats over arbitrary page content, and--popoveris the system's floating-surface pair ("menus, tooltips, popovers"). Everything else matches Alert so the two read as siblings. - The four feedback intents differ by icon shape and word, not colour alone — same rule as
Alert. Only the title takes the intent colour; body copy stays
--foreground. --shadow-lgis what signals "floating" — Shadows names it for "modals, dialogs, sheets", and a Toast is a peer of those.- No size, tone, or density variant.
States
Toast
| State | Behaviour |
|---|---|
| Entering | opacity 0 → 1 with a small upward translate over ~150 ms (ease-out). Under prefers-reduced-motion there is no transition — it appears in place. The title / description are in the DOM from the first frame so the live region announces the full message. |
| Visible | On the --{intent}-subtle (or --popover) surface, --shadow-lg, --radius-lg, --scale-4 padding. The auto-dismiss countdown is running (unless the intent persists, or the stack is paused). |
| Paused | The countdown for every visible toast is held while the pointer is over the viewport, while focus is inside it, or while the browser tab is hidden. It resumes from the remaining time. |
| Queued | Beyond max (default 3), a toast waits in a FIFO queue and is not rendered. Its countdown has not started. |
| Leaving | Removed immediately on dismiss — no exit animation in Version 1. |
Timing per intent
| Intent | Default auto-dismiss |
|---|---|
default / info / success | the provider duration — 5000 ms by default |
warning | 8000 ms |
destructive | persists until dismissed |
A caller duration (a number of ms, or Infinity) overrides the default for that toast.
Dismissal
ToastClose (✕), the auto-dismiss timer, running a ToastAction, Escape while focus is in
the viewport, or a programmatic dismiss(id) / dismiss(). There is no swipe gesture in
Version 1.
Placement
Set on the provider, never per toast — position has exactly two Version 1 values:
position | Desktop (--breakpoint-sm+) | Stack direction | Mobile |
|---|---|---|---|
bottom-right (default) | Anchored to the bottom-right corner, --scale-4 from each edge | Grows upward — newest nearest the bottom | Full-width near the bottom, --scale-4 inset each side |
top-right | Anchored to the top-right corner, --scale-4 from each edge | Grows downward — newest nearest the top | Full-width near the top, --scale-4 inset each side |
Each toast is max-w-sm on desktop (no dedicated width token — Q5); the --scale-4 viewport
inset and the toast sizing are the same for both positions. There is no top-left, bottom-left,
centre, or arbitrary-offset placement in Version 1.
Stacking
max (default 3) toasts are visible, newest nearest the anchored edge. A sixth toast()
call while three are showing and two are queued joins the back of the queue; a visible toast is
never silently evicted to make room. A toast's countdown starts when it becomes visible, so
a queued toast still gets its full time on screen.
Usage Guidance
Known limitation: toasts and modal layers
A toast fired while a Dialog, Sheet, or command palette is open is not supported. Everything behind an open modal layer — the Toast viewport included — is inert, so:
- The toast is drawn underneath the scrim, covered and dimmed.
- It cannot be focused, clicked, or dismissed, and it is not announced by assistive tech.
- The auto-dismiss countdown keeps running (it pauses only on pointer-over, focus-within, and
a hidden tab), so a default-duration toast can expire before the modal closes and never be seen.
Persistent toasts (
warning/destructive, orduration: Infinity) survive and become fully interactive again once the modal closes.
What to do instead: close the modal first and then call toast() (for example in the confirm
handler, after closing), or report the result inline inside the modal with an Alert or
a field-level error.
Tokens
Toast reuses the feedback colour family Alert uses — --{intent}-subtle / --{intent}-subtle-foreground / --{intent}-subtle-border for info / success / warning / destructive — plus --popover for the neutral surface, --shadow-lg for elevation, and existing --border / --radius-lg / --ring / --scale-*. It defines no toast-* tokens. See Color — Feedback.
| Token | Where used | Rationale |
|---|---|---|
--{intent}-subtle / -subtle-foreground / -subtle-border | the four feedback intents — surface / title + icon / edge | The feedback family Alert defined; Toast is the second documented consumer (Color) |
--popover / --popover-foreground | default (neutral) surface + text | Color names this pair for floating surfaces — a Toast floats |
--border | default edge | The system hairline |
--foreground | body copy on every feedback intent | Same as Alert — a coloured body paragraph reads poorly |
--shadow-lg | elevation | Shadows — "modals, dialogs, sheets"; a Toast is a peer |
--radius-lg | corner | Radius — "cards, popovers"; matches Alert |
--ring | focus ring on ToastAction / ToastClose | The system focus ring, via buttonVariants |
--scale-4 | toast padding; viewport inset from the screen edge | Spacing |
--scale-3 | gap between stacked toasts; icon-to-content gap | The same 12px rhythm Alert uses |
--scale-5 | intent-icon glyph size (20px) | Icons — "default UI" size, matching Alert |
--scale-8 | the desktop viewport width allowance around a max-w-sm toast | Spacing |
--scale-px | toast border width | Color border recipe |
Contrast is inherited from the feedback family, measured for Alert (WCAG 2.1 AA): body
--foreground on each --{intent}-subtle is 15–19:1; title / icon --{intent}-subtle-foreground
on the same surface is 5.8–11.1:1. --popover-foreground on --popover is near-maximal in both
themes.
Do / Don’t
Do
- Use a Toast for feedback that is safe to miss: a confirmation, a background result, a
recoverable error with an
Undo. - Keep the title to a few words and the description to one sentence. A Toast is glanceable.
- Match the intent to the message, exactly as with Alert —
successfor a completed action,destructivefor a failure,warningfor a consequence,infofor a neutral note. - Pair a destructive action with a
ToastActionUndowhere you can, instead of a blocking confirmation. - Let
warninganddestructivepersist (the default) so the user actually sees them.
Don’t
- Don't put anything required in a Toast — no "click here to keep your work", no form, no long copy. If it can't be missed, it isn't a Toast (Alert or Dialog).
- Don't stack more than one action into a toast, or a
ToastCloseplus two buttons — the✕and oneToastActionis the whole budget. - Don't fire a toast on every keystroke or poll tick. Debounce, or collapse repeats.
- Don't move focus to a Toast when it appears — it must never interrupt (see Accessibility).
- Don't rely on colour alone — the icon and the intent word carry the meaning, keep them.
- Don't fire a toast from inside an open Dialog or Sheet — see the known limitation below.
Accessibility
- Non-modal, never steals focus. Showing a Toast does not move focus, add a scrim, mark the
page
inert, trap focus, or lock scroll — the defining contrast with Dialog. The page stays fully operable underneath. - Announcement. Each toast's icon + intent word + title + description sit in a live region:
role="status"witharia-live="polite"fordefault/info/success;role="alert"witharia-live="assertive"forwarning/destructive.aria-atomic="true"so the whole message is read, not a diff. The text is present at insertion (only opacity / transform animate), so the announcement is not truncated. - The viewport is a landmark.
ToastViewportisrole="region"witharia-label="Notifications"andtabIndex={-1}, so a keyboard or screen-reader user can move to accumulated toasts. It is placed last in<body>, soTabreaches the toasts' controls after the page content. - Keyboard.
Tab/Shift+Tabmove through each toast'sToastActionandToastClosein order — no roving tabindex, no trap.Escape, while focus is within the viewport, dismisses the toast that contains focus (or the newest, if focus is on the region itself); it is handled on the viewport, never as a global document listener. - Focus on dismiss. Dismissing a toast from the keyboard moves focus to the next toast's
✕, or the viewport, or the element that was focused before focus entered the viewport — it never drops to nowhere. Dismissal by the timer, a pointer, or adismiss()call leaves focus untouched. - Timing is adjustable (WCAG 2.2.1). The countdown pauses on pointer-over, on focus-within,
and while the tab is hidden, and resumes from the time remaining.
warninganddestructivedo not auto-dismiss at all. A caller can passduration: Infinityto make any toast persist. - Motion. The ~150 ms enter transition is disabled under
prefers-reduced-motion(motion-reduce:). There is no looping animation and no exit animation. - Colour is never the only signal — a distinct icon shape per intent plus the visually hidden intent word, exactly as Alert.