Toast (Snackbar) — spec¶
Status: example artifact for
component-spec-writerskill Generated by:skills/component-spec-writer, citing Ant Designmessage/notification, MUISnackbar, shadcn-uisonner
Purpose¶
A toast is transient feedback about an action — "Saved", "Failed to send", "Copied to clipboard". It doesn't interrupt; it appears, optionally auto-dismisses, doesn't require user input.
For interruption requiring decision → use Modal / AlertDialog.
For persistent in-page status → use Alert / Banner.
Anatomy¶
ToastContainer (singleton, portal-mounted, fixed position)
└── Toast[] (stacked)
└── Toast
├── [iconStart]? (status icon by intent: ✓, !, ⚠, ⊘)
├── Body
│ ├── Title
│ └── [Description]?
├── [Action]? (button — undo, retry, view)
└── [CloseButton]? (✕, optional)
| Part | Required | Notes |
|---|---|---|
| Title | yes | the message; ≤ 60 chars ideal |
| Description | optional | second line for detail |
| iconStart | usually | matches intent — provides redundant cue beyond color |
| Action | optional | one button max ("Undo", "Retry", "View") |
| CloseButton | optional | always provide for intent="error" and persistent toasts |
API¶
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string \| ReactNode |
— | Primary message. Required. |
description |
string \| ReactNode |
— | Secondary detail line. |
intent |
"info" \| "success" \| "warning" \| "error" |
"info" |
Sets icon, color, and ARIA role. |
duration |
number |
varies by intent (see below) | Auto-dismiss in ms. 0 or Infinity = persistent. |
action |
{ label: string, onClick: () => void } |
— | One action button. |
dismissible |
boolean |
true (always for error) |
Show ✕ close button. |
position |
"top-right" \| "top-center" \| "bottom-right" \| "bottom-center" \| "bottom-left" \| "top-left" |
"bottom-right" (desktop) / "top-center" (mobile) |
Container position. |
id |
string |
auto | For programmatic control (update/dismiss). |
Imperative API (canonical):
toast(title, options?)
toast.success(title, options?)
toast.error(title, options?)
toast.warning(title, options?)
toast.info(title, options?)
toast.loading(title, options?) // returns id
toast.dismiss(id?) // dismisses one or all
toast.update(id, options) // updates content (e.g., loading → success)
// Promise wrapper — common pattern for async actions
toast.promise(
fetchData(),
{
loading: 'Saving…',
success: 'Saved',
error: 'Could not save',
}
)
This imperative API is the convention (sonner, react-hot-toast, ant message, MUI useSnackbar). Composition-style toasts (<Toast>...</Toast> rendered inline) are an anti-pattern — toasts come from any code path, not just JSX.
Default duration by intent¶
| Intent | Duration | Reasoning |
|---|---|---|
info |
4000ms | Brief acknowledgement |
success |
3000ms | Slightly faster — confirms completion, less to read |
warning |
5000ms | More content typical, give time to absorb |
error |
Persistent (duration: Infinity) |
User must see and dismiss. Never auto-hide errors. |
loading |
Persistent | Updated to success/error via toast.update. |
Hovering pauses the timer. Resumes when mouse leaves.
States¶
| State | Trigger | Behavior |
|---|---|---|
| Entering | toast triggered | slide-in 250ms ease-out from edge, opacity 0→1 |
| Visible | settled | duration timer ticks; pauses on hover/focus |
| Action triggered | user clicks action button | optionally auto-dismiss after click |
| Exiting | timer or click ✕ | slide-out 200ms ease-in, opacity 1→0 |
| Hidden | unmounted | removed from DOM after exit transition |
Stacking and limits¶
- New toasts stack on top of existing.
- Max simultaneous: 3–5 (most systems cap at 3). Beyond that, oldest auto-dismisses.
- Same
idre-toasted: replaces in place rather than stacking. Useful for "still saving…" updates.
Position behavior¶
- Desktop: bottom-right is conventional (least intrusive). Top-right for important info that must be seen.
- Mobile: top-center, full-width minus 16px margin. Avoid bottom-anchored toasts on mobile — fingers cover them when toasting from a tap.
- Account for safe-area-inset on iOS:
padding-bottom: max(16px, env(safe-area-inset-bottom)).
Tokens consumed¶
--color-bg-elevated
--color-text-primary
--color-text-secondary
--color-success (intent="success")
--color-warning (intent="warning")
--color-error (intent="error")
--color-info (intent="info")
--color-border-default
--space-md
--space-sm
--radius-md
--shadow-2
--motion-default
--easing-out
--easing-in
--z-toast (above modals: typically z-modal + 100)
Accessibility¶
- Role:
role="status"forinfo/success(polite — does not interrupt screen reader).role="alert"forwarning/error(assertive — interrupts).aria-livecorresponding (polite/assertive) on the container.- Don't auto-focus the toast. It's transient feedback, not a modal.
- Action button in the toast: keyboard-reachable when focus is in the page; tab order includes it.
- Pause on focus as well as on hover — for keyboard users tabbing through the page, the toast shouldn't disappear before they reach it.
- Close button must have
aria-label="Dismiss notification". - Color is not the only signal: every toast has an intent icon (
✓!⚠⊘) — color-blind users see the icon. - Don't put critical info only in toasts: a toast that auto-dismisses is the wrong place for "Your file failed to save — re-upload required." Make
errorpersistent and provide an action.
Code example¶
// Setup at app root
<ToastContainer position="bottom-right" />
// Anywhere in code
import { toast } from "@/components/toast";
// Simple confirmation
toast.success("Saved");
// With description and action
toast.success("Project deleted", {
description: "All project data has been removed.",
action: {
label: "Undo",
onClick: () => restoreProject(id),
},
});
// Persistent error with action
toast.error("Could not save", {
description: "Check your network connection.",
action: {
label: "Retry",
onClick: () => save(),
},
});
// Promise wrapper (canonical async pattern)
toast.promise(saveProject(data), {
loading: "Saving…",
success: "Saved",
error: (err) => `Could not save: ${err.message}`,
});
// Programmatic update (loading → success/error)
const id = toast.loading("Uploading…");
upload().then(
() => toast.success("Uploaded", { id }),
() => toast.error("Upload failed", { id }),
);
Edge cases¶
- Many toasts at once (e.g., 10 errors fire in a row from a failed batch operation): collapse to one summary toast — "10 items failed to save. View errors." Don't blast 10 toasts.
- User navigates away mid-toast: toast follows the user (lives in portal at root, persists across routes). Cancel only on full app exit.
- Network offline: don't toast for every retry. One persistent "Connection lost" + auto-resolve when back online.
- Toast obscures the action that triggered it: if user clicks "Save" at the bottom-right, a bottom-right toast covers their next click. Position to the opposite edge of the user's likely next interaction.
- Reduced motion: skip slide-in/out, use opacity-only transition.
- Long text: wrap, don't truncate. Keep on screen until dismissed.
- Action and dismissible together: action button on left of body, ✕ on right. Don't stack actions.
Don't¶
- Don't auto-dismiss errors. Ever. User must acknowledge.
- Don't use toasts for decisions. "Are you sure?" → modal, not toast.
- Don't show login/signup prompts as toasts.
- Don't cover the bottom of the screen on mobile with persistent toasts — top-center.
- Don't make the entire toast clickable. Click target should be the explicit action button, not the toast body — accidental dismissal is otherwise easy.
- Don't combine
successanderrorin the sameintent="info"color — the intent is the cue, not just the message. - Don't toast on app load ("Welcome back!"). Friction with no value.
References¶
- Ant Design:
refs/ant-design/components/message/— single-line transient messages (closest to "toast").refs/ant-design/components/notification/— multi-line, with title and description (closest to this spec's full toast). Ant separates these by line count — most other systems combine.- MUI:
refs/mui/packages/mui-material/src/Snackbar/Snackbar.tsx— composition-style React component. The least ergonomic of the three for the imperative pattern; pair withuseSnackbarhook from notistack or build a custom queue. - shadcn-ui:
refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/sonner.tsx— wrapssonnerlibrary. Sonner is the modern de-facto standard; its API (this spec mirrors it) is the cleanest of the three.
API choices made:
- Imperative API as primary (toast.success(...)) — matches user mental model. Composition (<Toast> JSX) is rejected — toasts come from any code path, not from render.
- Single component for info / success / warning / error rather than separate Notification and Message (Ant's split): the line-count distinction is implementation, not interface.
- Default-persistent error: deviates from MUI/Ant defaults but matches user need. Errors auto-hiding silently are how bugs go unnoticed.
- toast.promise adopted from sonner: canonical async pattern. Saves consumers from writing the .then(success).catch(error) boilerplate.
Cross-reference¶
- knowledge/motion/principles.md — toast enter/exit timing
- knowledge/a11y/keyboard-and-focus.md —
role="status"vsrole="alert" - WAI-ARIA Live Region documentation