Skip to content

Toast / status message

Live region · role=status / role=alert

A transient notification announced through a polite or assertive live region without stealing focus. The ARIA live-region pattern, where the hard part is invisible: the region has to exist before the message does.

Live example

Real and interactive — use it with the mouse, or Tab to it and use the keys below.

“Save changes” posts a polite status message that clears itself after a few seconds; “Trigger an error” posts an assertive alert that stays until you dismiss it. Neither one moves your focus — the message is announced in the background.

Keyboard

KeyAction
Tab / Shift + Tabreaches each visible toast's Dismiss button — toasts do not steal focus, so they sit in the normal tab order
Enter or Spaceon a focused Dismiss button, removes that toast

Screen reader

  • A polite status (“Changes saved”) is announced at the next natural pause, without interrupting whatever is currently being read.
  • An error is announced immediately as an alert, interrupting the current speech because it needs attention now.
  • Focus never moves to the toast — the user stays exactly where they were, and the message simply arrives in the background.

ARIA notes

  • The live region is rendered from first paint — empty — so assistive tech is already observing it when a message is inserted. A region that mounts together with its first message is frequently not announced at all.
  • role="status" carries an implicit aria-live="polite"; role="alert" an implicit aria-live="assertive". Match the politeness to the urgency — assertive is only for things that must interrupt.
  • aria-atomic="false" so only the newly added toast is announced, not the whole stack re-read on every change.

Code

The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.

src/components/library/Toast.tsx
import { useCallback, useRef, useState } from 'react'

// Accessible toast / status message — the WAI-ARIA live-region pattern.
//
// A toast tells the user something happened WITHOUT moving focus — that is what
// separates it from a dialog. The user is never yanked out of what they were
// doing; the message is announced by the screen reader in the background. Two
// things make that actually work, and both are invisible if you only read the
// visible markup:
//
//   1. The live region is in the DOM from the start — empty, waiting. A screen
//      reader only announces *changes* to a region it is already watching. If
//      the region and its first message mount together, the change is often
//      missed entirely. So <ToastRegion> is always rendered; only the toast
//      items inside it come and go. This is the single most common reason
//      hand-rolled toasts "work on screen but say nothing".
//   2. Politeness matches urgency. role="status" (an implicit aria-live
//      "polite") waits for a natural pause — correct for success/info.
//      role="alert" (implicit "assertive") interrupts immediately — reserved
//      for errors that genuinely need attention now. Both are used here, each
//      in its own always-present region.
//
// Dismissal policy is itself an accessibility decision (WCAG 2.2.1, Timing
// Adjustable): a polite status auto-clears after a few seconds, but an error
// (alert) stays until the user dismisses it — a message someone has to act on
// must not disappear on a timer. Nothing critical ever lives *only* inside an
// auto-dismissing toast.
//
// The visible entrance is a few-pixel rise (transform-only), removed entirely
// under prefers-reduced-motion — the meaning is carried by the role and the
// text, never by motion.

export type ToastTone = 'status' | 'alert'

export interface ToastMsg {
  id: number
  message: string
  tone: ToastTone
}

export interface NotifyOptions {
  tone?: ToastTone
  // Milliseconds until auto-dismiss, or null to stay until dismissed by hand.
  // Default: alerts persist (null), polite status messages clear after 5s.
  duration?: number | null
}

export function useToasts() {
  const [toasts, setToasts] = useState<ToastMsg[]>([])
  const idRef = useRef(0)
  const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())

  const dismiss = useCallback((id: number) => {
    setToasts((list) => list.filter((t) => t.id !== id))
    const timer = timers.current.get(id)
    if (timer) {
      clearTimeout(timer)
      timers.current.delete(id)
    }
  }, [])

  const notify = useCallback(
    (message: string, opts: NotifyOptions = {}) => {
      const tone = opts.tone ?? 'status'
      const duration = opts.duration === undefined ? (tone === 'alert' ? null : 5000) : opts.duration
      const id = ++idRef.current
      setToasts((list) => [...list, { id, message, tone }])
      if (duration != null) {
        timers.current.set(
          id,
          setTimeout(() => dismiss(id), duration),
        )
      }
      return id
    },
    [dismiss],
  )

  return { toasts, notify, dismiss }
}

function ToastItem({ toast, onDismiss }: { toast: ToastMsg; onDismiss: (id: number) => void }) {
  const isAlert = toast.tone === 'alert'
  return (
    <div
      className={`toast-item flex items-start gap-3 rounded-xl border p-3 shadow-md ${
        isAlert
          ? 'border-[color:var(--color-critical-border)] bg-[color:var(--color-critical-soft)]'
          : 'border-[color:var(--color-success-border)] bg-[color:var(--color-success-soft)]'
      }`}
    >
      <span
        aria-hidden="true"
        className={`mt-0.5 shrink-0 ${isAlert ? 'text-[color:var(--color-critical)]' : 'text-[color:var(--color-success)]'}`}
      >
        {isAlert ? (
          <svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75">
            <circle cx="8" cy="8" r="6.25" />
            <path d="M8 5v3.5M8 11h.01" strokeLinecap="round" />
          </svg>
        ) : (
          <svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75">
            <circle cx="8" cy="8" r="6.25" />
            <path d="M5.25 8.25 7 10l3.75-4.25" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        )}
      </span>
      <p className="flex-1 text-sm text-[color:var(--color-on-surface)]">{toast.message}</p>
      <button
        type="button"
        onClick={() => onDismiss(toast.id)}
        aria-label="Dismiss notification"
        className="shrink-0 rounded-lg p-1 text-on-surface-variant hover:bg-black/5 hover:text-on-surface"
      >
        <svg viewBox="0 0 12 12" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.75">
          <path d="M3 3l6 6M9 3l-6 6" strokeLinecap="round" />
        </svg>
      </button>
    </div>
  )
}

// Always-rendered live regions. The outer element is present from first paint,
// so assistive tech is already watching when a toast is inserted. Alerts sit in
// an assertive region, polite status messages in their own polite region.
export function ToastRegion({ toasts, onDismiss }: { toasts: ToastMsg[]; onDismiss: (id: number) => void }) {
  const alerts = toasts.filter((t) => t.tone === 'alert')
  const statuses = toasts.filter((t) => t.tone === 'status')
  return (
    <div className="toast-viewport pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
      <div role="alert" aria-live="assertive" aria-atomic="false" className="flex flex-col gap-2 [&>*]:pointer-events-auto">
        {alerts.map((t) => (
          <ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
        ))}
      </div>
      <div role="status" aria-live="polite" aria-atomic="false" className="flex flex-col gap-2 [&>*]:pointer-events-auto">
        {statuses.map((t) => (
          <ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
        ))}
      </div>
    </div>
  )
}

Accessibility pitfalls

  • AvoidThe toast element is added to the DOM only when the first message fires.

    DoRender the empty live region up front and insert only the message text into it — otherwise the very first toast is silent.

  • AvoidEverything uses role="alert", so a routine “saved” message rudely interrupts the screen reader.

    DoUse role="status" (polite) for success and info; reserve role="alert" (assertive) for errors.

  • AvoidFocus is moved onto the toast so it “gets noticed”.

    DoLeave focus where it is — a live region announces without stealing focus, which is the entire point of the pattern.

  • AvoidAn error toast auto-dismisses after three seconds, before a screen-reader user can read or act on it.

    DoLet errors persist until dismissed; only auto-clear low-stakes polite messages (WCAG 2.2.1, Timing Adjustable).