← All stories

COMPONENT · ui-toast

ui-toast

Component Tier 1 (primitive) Used for confirmations + non-blocking notices

Toasts are easy to ship and hard to ship right. Auto-dismiss timing depends on content length AND user reading speed AND screen-reader announcement timing. Multiple toasts firing in rapid succession need a queue. Action buttons inside toasts need keyboard reachability without breaking the auto-dismiss UX. Mobile placement needs to clear the iOS home indicator. ui-toast encodes the answers once.

Component contract

Every toast in the admin is fired through the canonical useToast() hook which mounts <UiToast>. No hand-rolled toasts.

  • title: string — required, the toast's primary line.
  • description?: string — optional secondary line.
  • variant?: "info" | "success" | "warning" | "error" — defaults info. Affects color and icon, not auto-dismiss timing.
  • action?: { label, onClick } — optional button inside the toast (e.g., "Undo").
  • autoDismissMs?: number | null — defaults to a content-length-aware computation: min(8000, max(3000, 2500 + 50ms × text length)). Set to null to require manual dismiss (used for errors that need user acknowledgment).
  • id?: string — for deduplication when the same toast is fired multiple times.

Interaction surface

  1. Caller fires a toast via useToast().show({...}).

    A new toast appears in the top-right region (or top-center on mobile). If a toast with the same id is currently visible, the new one replaces it (no duplicate flash). Up to 3 toasts can be visible at once; further toasts queue.

  2. Toast announces itself.

    The toast region is an aria-live="polite" for info/success, aria-live="assertive" for warning/error. Title + description are read in sequence.

  3. Auto-dismiss timer.

    Default formula gives ~5s for a typical confirmation. Pause when hovered. Pause when focused (e.g., user tabbing to the action button). Resume on mouseleave / blur.

  4. Action button.

    If action is provided, the button is keyboard-reachable via Tab. Clicking it fires onClick AND dismisses the toast. Tab from the toast region also moves out without dismissing.

  5. Manual dismiss.

    Each toast has a close button (X icon) with aria-label="Dismiss". Esc focused on the toast region dismisses the focused toast.

Failure modes

Auto-dismiss too short for long content

Trigger: a toast with description "We've sent invitations to 247 guests across 3 access types and 2 designs. Email deliverability events will appear in the report center within 2 hours." auto-dismisses in 3 seconds.

Auto-dismiss timing is content-length-aware. The formula gives that toast ~14 seconds (capped at 8s by the formula's max — so we'd actually adjust the cap, OR the toast warrants autoDismissMs: null requiring manual dismiss). Component clamps to a minimum of 3s. The harness asserts: long-content toast survives at least the computed timing.

Auto-dismiss does not pause on hover

Trigger: user hovers a toast to read it; toast disappears mid-sentence.

Hover pauses the timer. Mouseleave resumes (with the remaining time, not a full reset). The harness asserts: hover before timer expires, wait past expiry, observe toast still visible.

Stacked toasts overflow the viewport

Trigger: 5 toasts fire in 2 seconds. All 5 render stacked vertically; the lowest toasts run off the bottom.

Maximum 3 visible toasts at once. Further toasts queue and appear as earlier ones dismiss. The harness asserts: fire 5 toasts, observe count of data-test=ui-toast elements ≤ 3.

Action button steals focus

Trigger: user is mid-type in a form, a success toast with an Undo action fires, the Undo button auto-focuses, user's keystroke hits the toast.

Toast does NOT auto-focus. Action button is reachable via Tab from the body, but focus is not shifted automatically. The harness asserts: fire toast with action, observe document.activeElement unchanged from before fire.

Duplicate toasts on rapid fire

Trigger: a button click fires the same "Saved" toast twice via a race in the caller. Both render briefly, looking like a stutter.

If the caller provides an id, dedup by id — the second show replaces the first in place. If no id, two toasts render (caller didn't ask for dedup). The harness asserts: fire two toasts with same id within 100ms, observe one visible toast.

Toast covers the user's primary action

Trigger: top-right placement covers the workspace switcher in the page header at narrow viewports.

On viewports < 768px wide, toast moves to top-center. Doesn't overlap header chrome regardless of width. The harness asserts: at 375x667, fire a toast, compute its bounding rect, observe it does not overlap [data-test=workspace-name] bbox.

Accessibility

  • Toast region is role="region" aria-label="Notifications".
  • Each toast is role="status" (info/success) or role="alert" (warning/error).
  • Live region politeness matches the variant.
  • Close button has aria-label="Dismiss" with the toast title context.
  • Action buttons have descriptive labels (not just "Undo" — "Undo invitation send" with context).
  • Color contrast: text ≥ 4.5:1, action button ≥ 4.5:1 against toast background.
  • axe-clean at severity ≥ serious.

Stable test attributes

Visibility teeth. Each attribute must be present AND effectively visible while the toast is mounted. After auto-dismiss, the toast should be removed from the DOM (preferred) or marked display:none — both render the selector absent per the visibility-teeth contract.

data-testWherePurpose
ui-toast-regionDocument body portalThe aria-live region; always present (empty when no toasts)
ui-toastInside ui-toast-regionEach toast; multiple instances
ui-toast-titleInside ui-toastTitle text
ui-toast-descriptionInside ui-toastDescription text; absent if not provided
ui-toast-actionInside ui-toastAction button; absent if no action prop
ui-toast-dismissInside ui-toastClose button (X icon)

Agent test plan

Probe list
- auto-dismiss-default-timing: fire toast with 50-char title, advance clock past computed timing, assert toast absent
- auto-dismiss-min-3s-floor: fire toast with 5-char title, observe minimum 3000ms before dismiss
- auto-dismiss-pauses-on-hover: fire toast, hover before expiry, advance clock past expiry, assert toast still visible
- auto-dismiss-resumes-on-mouseleave: continuation of above; mouseleave, advance remaining time, assert toast dismissed
- max-3-stacked: fire 5 toasts within 100ms, count visible ui-toast elements, assert ≤ 3
- queue-after-dismiss: continuation; dismiss one of the 3, observe a 4th appear
- dedup-by-id: fire two toasts with same id within 100ms, assert exactly one visible
- no-auto-focus-steal: focus an input, fire toast with action, assert document.activeElement unchanged
- action-tab-reachable: fire toast with action, Tab from body, assert action button is in tab sequence
- esc-dismisses-focused: focus a toast, press Esc, assert toast dismissed
- close-button-dismisses: click ui-toast-dismiss, assert toast removed
- mobile-placement: at 375x667, fire toast, compute bbox, assert not overlapping workspace-name bbox
- aria-live-polite-info: fire info variant, assert ui-toast-region aria-live="polite"
- aria-live-assertive-error: fire error variant, assert ui-toast-region aria-live="assertive"
- color-contrast: assert ui-toast-title text ≥ 4.5:1