Skip to the content.

Build a toast notification stack

Build a toast system: something anywhere in the app calls notify("Saved") and a message appears bottom-right, then disappears.

Starter files are in practice/workspace/toast-notifications/<framework>/.

Solution

Approach 1: one live region, toasts rendered into it

A single container carries the live region, and toasts are just children.

<div className="toast-stack">
  <div aria-live="polite" aria-atomic="false" className="toast-stack__region">
    {toasts.filter(t => t.tone !== "error").map(renderToast)}
  </div>
  <div role="alert" className="toast-stack__region">
    {toasts.filter(t => t.tone === "error").map(renderToast)}
  </div>
</div>

Two regions, because politeness is a property of the region and not of the message. A polite region waits for the user to pause; role="alert" (which is assertive plus atomic) interrupts. Putting an error into the polite region means the user hears about a failed save after whatever they were reading — which is the wrong order for the one message that needs acting on.

The regions must exist in the DOM before the first toast is added. Injecting a live region and its content in the same tick is the single most common reason a toast is never announced: the screen reader has nothing to observe a change against.

Approach 2: a toast component that owns its own timer

Each toast holds its own setTimeout and pause state, and calls onDismiss(id) when it expires.

function Toast({ id, message, tone, onDismiss }) {
  const [paused, setPaused] = useState(false);
  useEffect(() => {
    if (paused || tone === "error") return;
    const timer = setTimeout(() => onDismiss(id), 5000);
    return () => clearTimeout(timer);
  }, [paused, tone, id, onDismiss]);
  return (
    <div className={`toast toast--${tone}`}
         onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}
         onFocusCapture={() => setPaused(true)} onBlurCapture={() => setPaused(false)}>
      <p className="toast__message">{message}</p>
      <button type="button" onClick={() => onDismiss(id)} aria-label={`Dismiss: ${message}`}>×</button>
    </div>
  );
}

Local timers keep the parent a plain list and make pausing trivial — the effect re-runs and the old timeout is cleaned up. The cost is that the parent cannot answer “how long until this clears”, which matters if you ever want a progress bar on the toast.

A single parent timer over a sorted queue gives you that, and buys a great deal of bookkeeping you do not need yet.

Trade-offs

Focus is the decision that separates a good toast from a hostile one. Never move focus to a toast. A toast is not a dialog: the user did not ask for it, and yanking focus mid-typing loses keystrokes and their place. The consequence is that the close button is unreachable by Tab until focus naturally arrives there, which is why auto-dismiss and the live-region announcement have to carry the weight.

Pausing on focus, not just hover, is the accessibility fix people skip. A keyboard user who tabs into a toast to dismiss it will otherwise watch it vanish mid-reach. onFocusCapture / onBlurCapture (or focusin/focusout on the element) is two lines and it is what makes the timer humane.

Errors that auto-dismiss are errors that never happened. Five seconds is fine for “Saved”. It is not fine for “Could not save — your changes are still here”, which needs to survive until it is read. Tie the dismissal policy to tone, not to a global constant.

Stacking limit. An unbounded stack covers the screen during a retry storm. Cap it (three is common), drop the oldest, and collapse duplicates by message — otherwise a failing poll turns your notification system into a denial of service on your own UI.