콘텐츠로 이동

Toast (Snackbar) — spec

Status: example artifact for component-spec-writer skill Generated by: skills/component-spec-writer, citing Ant Design message/notification, MUI Snackbar, shadcn-ui sonner

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 id re-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" for info / success (polite — does not interrupt screen reader).
  • role="alert" for warning / error (assertive — interrupts).
  • aria-live corresponding (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 error persistent 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 success and error in the same intent="info" color — the intent is the cue, not just the message.
  • Don't toast on app load ("Welcome back!"). Friction with no value.

References

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