Design a toast notification system
A toast system for a design system: small messages like "Saved" or "Message deleted. Undo" that slide in, wait, and leave on their own. Drawing one is easy; the hard parts are an API any code can call, a store outside React, a queue with a visible limit, fair timing that pauses for the reader, Undo that commits only on expiry, and announcements screen readers actually hear.
The question
Design a toast notification system for our design system. Any part of the app should be able to show a short message: "Saved", "Couldn't upload photo.jpg. Retry", or "3 emails archived. Undo".
Walk me through the API teams call, where the toasts live, how many can show at once, how long they stay, and how they work for keyboard and screen reader users. What do you build, and what does the browser already give you?
Real-life examples: Gmail's "Conversation archived. Undo" bar, GitHub's "Copied!" and flash messages, Vercel's deployment toasts, Slack's "Message deleted" notice, Google Docs' "Saving..." and "All changes saved"
Requirements: what to ask before drawing anything
A toast is a small, short-lived message that appears at the edge of the screen, usually a corner, tells the user something happened, and then goes away by itself. The name comes from toast popping up out of a toaster. Other names you'll hear: snackbar (Material Design), notification, flash message.
A toast is not a dialog. It does not block the page, it does not take keyboard focus, and the user can ignore it. That is the whole point: it reports, it doesn't interrupt. If the user must answer before going on, you want a modal dialog instead (see the modal-dialog guide).
This is an easy question because the visible part is a rounded box with text. The interviewer wants to see the invisible part: who can create a toast and from where, what happens when 12 arrive in one second, whether a slow reader gets enough time, and whether a blind user hears "Saved" at all.
Questions that change the design
| Question | Answer we assume | What it changes |
|---|---|---|
| Who creates toasts? | Any code: click handlers in components, but also the API client after a failed request, a WebSocket handler, and a service that runs outside React. | The API must be callable from plain functions, not only from hooks. This pushes the store outside React. |
| What kinds of messages? | Four severities: success, info, warning, error. Plus a loading toast that turns into success or error when a promise settles. | Different roles for screen readers, different timings, and an update-in-place API. |
| Can a toast have a button? | Yes, at most one action ("Undo", "Retry", "View") plus a close button. | Timing rules: a toast with an action must give enough time to reach it, and errors with actions don't vanish by themselves. |
| How many at once? | Bursts happen: a bulk action can fail 20 times. We show at most 3 and queue the rest. | A queue, a visible limit, and deduplication of identical messages. |
| Where on screen? | Bottom right on desktop by default; teams can choose one of 6 positions. Bottom center, full width on phones. | Layout and stacking direction depend on position. Swipe to dismiss on touch. |
| Accessibility target? | WCAG 2.2 level AA. | A live region, timing that can be paused, no focus stealing, reduced motion. |
| Server rendering? | Yes, the app uses Next.js with server rendering. | No toasts on the server; the live region must exist in the HTML before the first toast. |
| Bundle budget? | Under 4 KB compressed for the core, since every page loads it. | No animation library; CSS transitions and a tiny store. |
Functional requirements
- Show a toast from anywhere with one call, like
toast.success("Saved"), and get back an id. - Update a toast in place by id ("Uploading 40%" to "Uploaded") and dismiss it by id or all at once.
- Tie a toast to a promise: loading while it runs, then success or error text when it settles.
- Show at most 3 toasts at once; queue the rest and show them as space frees up.
- Auto-dismiss after a time based on severity and text length; pause the timer while the user hovers, focuses, or has the tab hidden.
- One optional action button (Undo, Retry) and a close button on every toast.
- Merge identical toasts fired in a short window instead of stacking copies.
- Six positions; collapsed stack that expands on hover or focus; swipe to dismiss on touch.
Non-functional requirements
- Accessible: every toast is announced once by screen readers, never steals focus, and its action can be reached by keyboard.
- Fast: showing a toast re-renders only the toaster, not the app. Animations use
transformandopacityonly, so they stay on the compositor at 60 frames per second. - Small: under 4 KB compressed for the store, hook, and toaster together.
- Safe: toast text is rendered as text, never as HTML, because messages often contain user data like file names.
- Predictable for teams: sensible defaults, so a team that only writes
toast("Saved")gets correct timing and announcements.
Out of scope
- A notification center or inbox that keeps history: that's a separate feature with its own storage (see the notifications-center problem). We can hand dismissed toasts to it later.
- Browser push notifications, which show outside the page through the operating system.
- Toasts shared across tabs. Each tab has its own toaster; a "Saved" in tab A shouldn't appear in tab B.
Check your understanding · Exercise 1
Which answers to scope questions change the architecture of the toast system, not just its looks? Select all that apply.
What makes toasts hard
Five properties of toasts drive every decision later in this guide. Name them out loud early in the interview: it shows you see past the box.
- Called from anywhere, shown in one place. A toast is created deep in a form, in an API client, or in a socket handler, but rendered in one corner of the app. The caller and the renderer don't know each other. That's a publish and subscribe problem, not a props problem.
- Temporary by design. A toast removes itself. So the system owns timers, and timers interact with people: a slow reader, a user who hovers to read, a user who switched tabs. A toast that vanishes while someone is reading it is a bug, and for some users an accessibility failure.
- Bursty. Messages arrive in clumps. Select 20 rows, press Delete, lose the network: 20 "Couldn't delete" errors in 200 ms. Without a limit and deduplication, the corner fills with copies and covers the page.
- Invisible to some users unless announced. A sighted user sees the toast slide in. A screen reader user hears nothing unless the text lands in a live region: an element the screen reader watches for changes. Live regions are fragile: if the region is created at the same moment as its text, many screen readers say nothing.
- Sits above everything, but must not block anything. The toast must appear above sticky headers, drawers, and even open modal dialogs, yet never take focus, never trap the keyboard, and never cover the button the user is about to press.
Here is a concrete failure story to keep in mind. A user in a mail app selects 5 emails and presses Delete. The app deletes them on the server at once and shows "5 emails deleted. Undo" for 4 seconds.
The user is mid-sentence in a chat with a colleague, looks back after 6 seconds, and the toast is gone. The emails are gone too, and there is no Undo anywhere. Three things failed together: the timer ignored the user, the action was too short-lived, and the delete was committed too early. All three come from the second property: a toast removes itself, so its timing is a design decision, not a detail.
Think first
Your teammate wants to show "Your session expires in 60 seconds. Stay signed in?" as a toast. Is that a good use of a toast?
No. The user must act, and missing it has a real cost: losing their work. A toast is easy to miss by design, and a screen reader user may hear it once while busy elsewhere.
Use a modal dialog (an alertdialog) that takes focus. One way to meet WCAG 2.2.1 (Timing Adjustable) is to warn the user before the time runs out and give them at least 20 seconds to extend it with a simple action, which is exactly this dialog.
Rule of thumb: if ignoring the message causes harm, it's not a toast.
When a toast is the right tool
| Situation | Right tool | Why |
|---|---|---|
| "Saved" after an explicit save | Toast (success) | Confirms a result the user expects; safe to miss. |
| "3 emails archived. Undo" | Toast with action | Short window to reverse an action; the app keeps working meanwhile. |
| Form field is invalid | Inline error next to the field | The message belongs to a place on the page, not to the corner. |
| "Delete this repository?" | Modal dialog | The user must decide before anything happens. |
| Offline for the last 10 minutes | Persistent banner | A lasting state, not an event. It stays until the state changes. |
| New message from Alex | Notification center plus a toast | The toast is a nudge; the history lives elsewhere. |
Check your understanding · Exercise 2
A bulk action fails 20 times in 200 ms. Which outcomes would you call bugs in a toast system? Select all that apply.
The API: imperative calls or declarative state?
Start with the API, because it decides everything else. Most components in a design system are declarative: you render them with props, and what you render is what you get. Can a toast work the same way?
The naive approach: a toast is a component with an open prop
function SaveButton() {
const [saved, setSaved] = useState(false);
async function onSave() {
await save();
setSaved(true);
}
return (
<>
<button onClick={onSave}>Save</button>
<Toast open={saved} onOpenChange={setSaved}>Saved</Toast>
</>
);
}
This works for one button. It breaks quickly. First, the toast is rendered inside SaveButton, so if the button unmounts (the user navigates away right after saving), the toast disappears mid-read.
Second, two buttons each render their own toast, and nothing knows there are now two: no limit, no stacking, no order. Third, the API client and the WebSocket handler aren't components. They can't call useState, so they can't show a toast at all.
The root issue: a toast is an event ("something happened"), not a piece of UI that belongs to the caller. Events are naturally expressed as function calls.
Decision How do callers create toasts?
What we pick: Pick the imperative function backed by an external store. It's what popular libraries such as Sonner and react-hot-toast do, and it fits the event nature of toasts. Keep a declarative escape hatch: the Toaster itself is a normal component with props (position, max visible, theme), so layout stays declarative while creation is imperative.
The API surface
type ToastId = string;
toast("Draft saved"); // default, info
toast.success("Profile updated");
toast.error("Couldn't upload photo.jpg", {
action: { label: "Retry", onClick: () => upload(file) },
});
const id = toast.loading("Uploading 0%");
toast.update(id, { message: "Uploading 40%" });
toast.dismiss(id); // one
toast.dismiss(); // all
toast.promise(saveDraft(), {
loading: "Saving...",
success: (draft) => `Saved ${draft.title}`,
error: (err) => `Couldn't save: ${err.message}`,
});
Why return an id? Because many toasts change over time. An upload toast goes from 0% to 40% to "Uploaded". With an id, the caller updates the same toast in place instead of stacking three.
Callers can also pass their own id, like toast.error(msg, { id: "network" }): a second call with the same id updates the existing toast rather than adding one. That's the simplest form of deduplication, and the caller controls it.
toast.promise is sugar for the loading pattern. It creates a loading toast, waits for the promise, then updates the same id to success or error. It returns the original promise, so the caller can still await it. The figure below follows one save that fails.
toast.promise returns the original promise, so the caller can still handle the error.When a declarative toast still makes sense
Some messages mirror a state, not an event: "You're offline" should show while offline and hide when back online. Calling toast() on the transition works, but the caller must remember the id and dismiss it later.
A small hook bridges the two: useToastWhile(isOffline, "You're offline") calls toast() with a fixed id when the condition becomes true and toast.dismiss(id) when it becomes false or the component unmounts. The store stays the single source of truth; the hook is just a convenience on top. The component-api-design lesson covers this "imperative core, declarative wrapper" pattern in general.
Check your understanding · Exercise 3
Why does toast() return an id? Select all that apply.
Where the toasts live: a tiny store outside React
The imperative API needs somewhere to put toasts that isn't a component. That place is a store: a plain object that holds the list of toasts, lets anyone change it, and tells subscribers when it changes. The toaster component subscribes and re-renders. Nothing else in the app does.
Decision Where does the toast list live?
What we pick: Use our own tiny external store read through useSyncExternalStore. It's the only option that is reachable from plain functions without depending on the host app's state library. The global-vs-local-state lesson explains why useSyncExternalStore exists: it lets React read outside state safely during concurrent rendering, without one part of the screen showing old data while another shows new ("tearing").
The store, in full
type Listener = () => void;
let toasts: readonly ToastRecord[] = [];
const listeners = new Set<Listener>();
function emit(next: readonly ToastRecord[]) {
toasts = next;
listeners.forEach((l) => l());
}
export const store = {
subscribe(l: Listener) {
listeners.add(l);
return () => listeners.delete(l);
},
getSnapshot: () => toasts,
getServerSnapshot: () => EMPTY, // always the same empty array
add(t: ToastRecord) { emit([...toasts, t]); },
update(id: string, patch: Partial<ToastRecord>) {
emit(toasts.map((t) => (t.id === id ? { ...t, ...patch } : t)));
},
remove(id: string) { emit(toasts.filter((t) => t.id !== id)); },
};
const EMPTY: readonly ToastRecord[] = [];
Two details matter. First, getSnapshot must return the same reference when nothing changed. If it built a new array on every call, React would think the store changed on every render and loop. That's why writes replace the array and reads just return it.
Second, getServerSnapshot returns one constant empty array. During server rendering and hydration there are no toasts, and returning a fresh [] each time would again look like a change.
export function Toaster(props: ToasterProps) {
const all = useSyncExternalStore(
store.subscribe, store.getSnapshot, store.getServerSnapshot,
);
const mine = all.filter((t) => t.toasterId === (props.id ?? "default"));
return <ToastRegion {...props} toasts={mine} />;
}
Multiple toasters
Most apps want one toaster. But a few need more: a code editor might show build results inside the editor panel while the rest of the app uses the bottom-right corner. Support this with a toasterId option: toast("Build passed", { toasterId: "editor" }) lands in the Toaster that has id="editor".
The store stays single; each toaster filters its own toasts. If no toaster with that id is mounted, the default toaster shows the toast instead, so a message is never silently lost. That fallback needs a small registry of mounted toaster ids, which the short code above leaves out.
Where do you mount the default Toaster? Once, near the root layout, outside any route that can unmount. If it lived inside a page, navigating would remove it, and "Saved" fired just before navigation would never appear.
Check your understanding · Exercise 4
A teammate writes getSnapshot: () => toasts.filter((t) => !t.dismissed). What goes wrong? Select all that apply.
The queue: how many show, in what order, and how they stack
Now the bursts. The store holds every toast that was created and not yet removed. The toaster decides which of them are visible and which wait in a queue. Keep these two ideas apart: "in the store" is not "on screen".
The naive approach: show everything
Render every toast in the store as its own box. With 20 errors, each box 64 px tall plus 8 px gaps, the column is about 1,430 px tall: taller than a 1080p screen. It covers the page, including the Retry button in the app itself. Worse, all 20 timers run at once, so they all vanish together 5 seconds later and the user read none of them.
Decision What happens when more toasts arrive than fit?
What we pick: Combine the last two. The toaster shows at most 3 toasts as a collapsed stack (newest in front, two peeking behind), expanding on hover or focus. Anything beyond 3 waits in a queue with its timer not started. Then cap the queue: a toast that has waited more than 30 seconds is dropped unless it's an error, because "Saved" a minute late is noise. Sonner, for example, uses a collapsed stack with a default of 3 visible toasts.
Order and position
Newest toast goes closest to the edge it grows from, so it appears where the eye already is. For a bottom position the stack grows upward: the newest sits at the bottom, nearest the corner. For a top position it grows downward: newest at the top. The rule in one line: new toasts enter at the screen edge and push older ones toward the middle.
| Position | Grows toward | Good for | Watch out for |
|---|---|---|---|
| bottom-right (default) | Up | Desktop apps; away from main navigation | Chat widgets and cookie banners in the same corner |
| bottom-center | Up | Phones (full width minus 16 px margins) | The on-screen keyboard and bottom nav bars; respect env(safe-area-inset-bottom) |
| top-center | Down | Short status like "Copied" | Covers headers and search boxes |
| top-right, top-left, bottom-left | Away from the edge | Apps whose other corners are busy | Right-to-left languages: mirror left and right with logical properties |
Try the simulator below. Fire 5 mixed toasts and step the clock: only visible toasts count down, and a queued toast starts its full timer when it moves up. Turn on "Pointer over stack" or "Tab hidden" and nothing expires. Fire the burst of errors with merging on and off, and change max visible to see how the queue length changes.
Deduplication and rate limiting
Even with a queue, 20 copies of the same error are useless. Three layers stop them, from cheapest to smartest:
- Caller ids.
toast.error(msg, { id: "save-failed" })replaces the existing toast with that id. The caller knows best when two messages are the same. - Automatic merge by content. If a toast with the same severity and text was created in the last 2 seconds and is still in the store, don't add a new one. Instead bump a counter on the existing one and restart its timer: the UI shows "Couldn't delete item (x20)". This catches callers who forgot ids.
- Rate limit. Allow at most, say, 5 new toasts per second overall. Extra ones are merged into a summary: "14 more notifications". This protects against a runaway loop, like a socket that reconnects 50 times a second and toasts each failure.
const MERGE_WINDOW_MS = 2000;
function add(input: ToastInput): string {
const key = `${input.type}:${input.message}`;
const twin = toasts.find(
(t) => t.key === key && Date.now() - t.createdAt < MERGE_WINDOW_MS,
);
if (twin && !input.id) {
store.update(twin.id, { count: twin.count + 1, remainingMs: twin.durationMs });
return twin.id;
}
const id = input.id ?? crypto.randomUUID();
if (input.id && toasts.some((t) => t.id === id)) {
store.update(id, toRecordPatch(input));
return id;
}
store.add(toRecord(id, key, input));
return id;
}
Check your understanding · Exercise 5
Max visible is 3. Five toasts arrive at once, each with a 5 second duration. The user doesn't touch anything. What should happen? Select all that apply.
Timing: how long a toast stays, and when the clock stops
A toast that leaves too fast is unread. A toast that stays too long becomes clutter that covers the page. The interviewer wants to hear that duration isn't one number, and that the clock must respect what the user is doing.
The naive approach: setTimeout(dismiss, 3000)
One fixed 3 second timer per toast, started at creation. Three things go wrong. A long error like "Couldn't upload quarterly-report-final-v3.pdf: file is larger than 25 MB" (about 70 characters) needs more than 3 seconds to read for many people, especially with a screen magnifier or in a second language.
The user hovers to read it and it vanishes under the pointer. And a user who switched to another tab comes back to find the message already gone, having shown to an empty room.
Duration by severity and length
| Kind | Default duration | Why |
|---|---|---|
| success, info | 4 s, plus reading time for long text | Confirms an expected result; safe to miss. |
| warning | 6 s, plus reading time | Something the user may want to act on later. |
| loading | No timer | It's replaced by success or error when the work ends. |
| error without action | 8 s, plus reading time | Important, but there is nothing to do in the toast. |
| any toast with an action (Undo, Retry) | At least 6 s; errors with actions: no timer | Reaching a button takes time, more so by keyboard. An error with Retry stays until handled or closed. |
"Reading time" here is a simple rule of ours: add 60 ms per character beyond the first 40, capped so the total never exceeds 10 seconds for messages without actions. A 70 character success message gets 4,000 + 30 x 60 = 5,800 ms. The exact numbers are a product choice; the principle is that longer text and more important text get more time.
Libraries differ. Sonner defaults to 4 seconds for every toast. react-hot-toast uses 2 seconds for success and 4 for errors, and never auto-dismisses loading toasts. Callers can always pass duration, including Infinity.
When the clock stops
- Pointer over the toaster. Hovering any toast pauses all visible toasts, and the stack expands. Pausing only the hovered toast lets its neighbours vanish and shift the layout under the pointer.
- Focus inside the toaster. A keyboard user who tabbed to the Undo button gets the same pause as a mouse user. Listen to
focusinandfocusouton the region. - Tab hidden. When
document.visibilityStateishidden(thevisibilitychangeevent), pause everything. The user can't read a tab they aren't looking at. - Window not focused (optional). On
blur, pause; onfocus, resume. This covers a user who clicked into another app while the tab stays visible on a second monitor.
Pausing means keeping remaining time, not restarting. Each toast stores remainingMs. On resume, record startedAt = performance.now() and set a timeout for remainingMs; on pause, clear the timeout and subtract the elapsed time. Use performance.now(), not Date.now(), because it can't jump when the system clock changes.
function resume(t: TimerState) {
if (t.remainingMs === Infinity || t.timer) return;
t.startedAt = performance.now();
t.timer = setTimeout(() => startExit(t.id), t.remainingMs);
}
function pause(t: TimerState) {
if (!t.timer) return;
clearTimeout(t.timer);
t.timer = undefined;
t.remainingMs -= performance.now() - t.startedAt;
}
Some designs restart the full duration on resume instead of continuing. That's kinder to readers who hovered because they were halfway through, and it's what you want after a long hidden tab. A good compromise: continue on hover and focus, but give at least 2 more seconds after the pointer leaves, and restart fully after the tab was hidden.
The lifecycle as a state machine
Put the states together and a toast has a clear lifecycle. Writing it as a state machine stops bugs like a timer firing on a toast that is already animating out, or a paused toast being resumed twice.
onRemove runs exactly once, with the reason.- queued: in the store, not on screen, no timer.
- visible: on screen, timer running.
- paused: on screen, timer stopped, remaining time kept.
- exiting: the exit animation is running (about 200 ms); ignores pause and update.
- removed: gone from the store;
onRemove(reason)runs exactly once.
What WCAG says about timing
WCAG 2.2.1 Timing Adjustable (level A) says that for each time limit set by the content, the user can turn it off, adjust it to at least 10 times the default, or extend it after a warning. Real-time and essential limits are exceptions.
Does a toast set a time limit? W3C's Understanding document answers with a toast example: a new-mail toast that disappears after 5 seconds sets no time limit, because the user can find the new mail in the inbox. If the toast is the only way to see the message or use its action, it must meet the criterion. An Undo that exists only in a disappearing toast is exactly that case.
Our design covers it three ways: errors with actions never auto-dismiss, a user setting ("Keep notifications until I close them") sets every duration to Infinity, which turns the limit off, and Undo is also offered somewhere stable, like a menu item or keyboard shortcut. Pausing on hover and focus helps every reader, but on its own it isn't one of the options the criterion lists.
WCAG 2.2.2 Pause, Stop, Hide covers two cases. Moving content that starts automatically, runs alongside other content, and lasts more than 5 seconds needs a way to pause, stop, or hide it; a slide-in that takes a fraction of a second is far below that. Auto-updating information has no 5 second exception, so a progress toast that keeps updating, or a stack that keeps cycling new messages, needs a control. The close button (hide) and pausing on hover and focus provide it.
Check your understanding · Exercise 6
A 5 second toast has been visible for 3 seconds. The user hovers it for 10 seconds, then moves away. What should happen? Select all that apply.
Actions and Undo: commit when the toast expires
A toast can carry one action. "Retry" and "View" are simple: the button calls a function and dismisses the toast. Undo is the interesting one, because it changes when the real work happens. Remember the failure story from chapter 2: the emails were deleted on the server at once, the toast left, and there was nothing to undo.
Decision When does the deleted thing actually get deleted?
What we pick: For reversible, everyday actions (archive, delete to trash, remove from list), hide now and commit when the toast expires. Gmail's Undo Send works the same way: the email isn't sent until the undo window you chose (5 to 30 seconds) ends. For actions the server must know about at once, delete now and restore with a second request. Keep confirm dialogs for truly destructive actions with no undo. This matches the optimistic-updates lesson: the UI changes first, and the toast is the visible end of the rollback window.
Wiring Undo to the toast lifecycle
The toast system doesn't know about emails. It only needs to tell the caller how the toast ended. So every toast reports one reason when it's removed: "timeout", "action", "close" (the user clicked X or swiped), or "replaced". The caller commits on everything except "action".
function archive(ids: string[]) {
mailStore.hide(ids); // optimistic
pending.set(ids.join(), ids);
toast(`${ids.length} emails archived`, {
duration: 8000,
action: { label: "Undo", onClick: () => mailStore.unhide(ids) },
onRemove: async (reason) => {
pending.delete(ids.join());
if (reason === "action") return; // undone, nothing to send
try {
await api.archive(ids);
} catch {
mailStore.unhide(ids);
toast.error("Couldn't archive. Emails restored.", {
action: { label: "Retry", onClick: () => archive(ids) },
});
}
},
});
}
Edge cases
- The user closes the tab during the window. Listen to
pagehide(more reliable thanbeforeunload, and it also fires when the page enters the back/forward cache) and flush pending commits withfetch(url, { method: "POST", keepalive: true })ornavigator.sendBeacon. Both let a small request outlive the page. On mobile, a background tab can be closed without any of these events firing, so for commits that must never be lost, also flush whenvisibilitychangereportshidden, at the cost of ending the Undo window early. - The user closes the toast with X. That's "I've seen it", not "undo". Commit at once instead of waiting for the timer.
- Two archives in a row. Each has its own toast and its own pending set. Merging them by text would be wrong here: "5 emails archived" twice are two different operations. Callers of Undo toasts should pass unique ids, and the toast system must never merge toasts that have an
onRemove. - The commit fails. Put the items back, then show an error toast with Retry that does not auto-dismiss. That's the failure path in the figure above.
- The user navigates within the app. The toast lives in the root toaster, so it survives route changes, and so does the pending commit. That's another reason to mount the toaster in the root layout.
Check your understanding · Exercise 7
With "hide now, commit on expiry", which events should send the archive request? Select all that apply.
Accessibility: announced once, never stealing focus
A sighted user notices a toast by its motion. A screen reader user notices nothing unless the text is placed in a live region: an element marked with aria-live (or a role that implies it) whose changes the screen reader reads out without moving focus. The screen-readers lesson covers live regions in general; here are the toast-specific decisions.
The naive approach: put role=alert on each toast
Render each toast as a new div with role="alert". Screen readers watch live regions for changes, so a region inserted together with its text is often missed; MDN's live region guide advises starting with an empty region that is already in the page. Browsers and screen readers do special-case role="alert" and usually announce it anyway, but that varies by pair and doesn't help polite messages. And alert is assertive: it interrupts whatever the user was hearing, which is rude for "Saved".
Decision How do toasts reach screen readers?
What we pick: Use the two hidden announcer regions, mounted with the toaster and empty from the first render, so they exist before any text arrives. Most toasts go to the polite status region and wait for the screen reader to finish its current sentence. Only errors that block the user's task go to the assertive alert region. The visible list is a labelled landmark (section with aria-label="Notifications") so users can find it, but it isn't a live region itself.
What text goes in? Write the announcement for a listener: "3 emails archived. Undo is available, press F8 to reach it." A screen reader user can't see a button in the corner, so tell them it exists and how to reach it.
For merged toasts, announce the first message, then wait until the burst settles (about 1 second without new copies) and announce one summary like "Couldn't delete 20 items". Progress updates from 0% to 100% are not announced one by one; only the start and the end.
Focus: never taken, always reachable
- Never move focus to a toast. The user may be typing; their next keystrokes would land on the Undo button. The focus-management lesson covers why focus moves must follow user intent.
- Offer a shortcut to jump in. Some libraries add a hotkey that moves focus into the toast region: Radix UI's Toast uses F8 by default, and Sonner uses Alt+T. Name the shortcut in the region's label, like "Notifications (F8)", so screen reader users discover it.
- Return focus when leaving. Remember the element that had focus before the jump. Escape, or the last toast closing, sends focus back there, not to the page body.
- Pause while focused. Covered in the timing chapter: a toast must not vanish under keyboard focus. If one is removed anyway (the user pressed its close button), move focus to the next toast, or back to the saved element if none is left.
- Provide a second path for the action. An Undo that lives only in a toast is easy to miss. Many apps also offer it from a menu or a keyboard shortcut for a while.
Motion, contrast, and zoom
Toasts slide and scale. Under @media (prefers-reduced-motion: reduce), swap the slide for a short opacity fade or no animation at all, and don't animate the stack expanding. The visual-accessibility lesson explains why: large motion can cause nausea for people with vestibular disorders.
Keep text contrast at 4.5:1 or more against the toast background, and don't rely on color alone for severity (add an icon and the word "Error" for screen readers). Let toasts wrap text instead of truncating at 400% zoom: a fixed 356 px wide toast with an ellipsis hides the very file name the user needs.
Check your understanding · Exercise 8
Your toaster renders a div role="status" with the message inside only when the first toast appears. NVDA users report hearing nothing for the first toast but hearing later ones. Which fixes address the cause? Pick one.
Rendering: above everything, on every device, never on the server
The toaster must paint above sticky headers, drawers, menus, and even open modal dialogs. Where you render it in the DOM decides whether that works.
Decision How does the toaster get above everything else?
What we pick: Use popover="manual" as the default layer: no z-index fights, and it paints above menus and other popovers. Then handle the modal case on purpose. Opening a modal dialog with showModal() makes everything outside it inert, popovers included. Inert content ignores clicks and is removed from the accessibility tree, so a toast can look on top yet be neither clickable nor announced, live regions included. While a modal dialog is open, route toasts to a second toaster rendered inside the dialog, with its own live regions (our toasterId routing from chapter 4 already supports this).
popover="manual" toaster can paint above a modal dialog, but anything outside the dialog is inert: not clickable and hidden from screen readers. While a dialog is open, toasts need a toaster inside it.One more top layer rule: elements are stacked in the order they entered it. If a dialog opens after the toaster popover, the dialog paints on top. Fix it by calling hidePopover() then showPopover() on the toaster when a new toast arrives, which moves it to the top again. The modal-dialog and dropdown-select guides cover the top layer and the Popover API in more depth.
Swipe to dismiss on touch
On phones the close button is a small target, so users expect to flick a toast away. Use Pointer Events, which cover touch, pen, and mouse with one API. On pointerdown, call setPointerCapture so moves keep coming even if the finger leaves the toast.
On pointermove, translate the toast with transform: translateX(...) and fade it with distance. On pointerup, dismiss if it moved more than about 45% of its width or the flick was fast (say over 0.5 px per ms); otherwise animate back. Set touch-action: pan-y on the toast so vertical scrolling still works while horizontal drags belong to us.
The thresholds are a product choice; Sonner, for example, dismisses after a 45 px drag or a quick flick. Swiping counts as a "close", not an Undo, and every swipe has a button equivalent for keyboard and switch users.
Server rendering and hydration
- No toasts on the server. Toasts come from user actions and timers, so the server always renders an empty toaster.
getServerSnapshotreturns the constant empty array, which keeps the server HTML and the first client render identical, so hydration doesn't mismatch. - Render the live regions in the server HTML. The empty
statusandalertregions are part of the toaster's markup from the start. That gives screen readers the whole page load to register them. Keep them outside the toaster's popover element: a closed popover isdisplay: none, which takes everything inside it out of the accessibility tree until the first toast opens it. - Toasts fired before hydration. Code may call
toast()before React hydrates (an error in an early script). The store is plain JavaScript, so it keeps them; once the toaster subscribes, it renders whatever is already there. - Toasts that cross a full page load, like "Signed in" after a redirect from the login server. Pass them as a one-time flash: a short-lived cookie or a query parameter like
?toast=signed-inthat the app reads on load, shows, and then removes withhistory.replaceState.
Styling and theming
Ship styles as CSS custom properties fed by the design tokens: --toast-bg, --toast-fg, --toast-border, --toast-radius, --toast-width, with one set per severity. Light and dark themes swap token values, not components. Expose data-type="error" and data-state="visible" attributes so teams can target states without class name guessing.
Use logical properties (inset-inline-end instead of right) so a right-to-left page mirrors the bottom-right corner to bottom-left automatically. In forced colors mode (Windows contrast themes), background colors and shadows are replaced by system colors, so keep a real 1 px border that stays visible.
Check your understanding · Exercise 9
A settings modal is open (opened with showModal()). Saving fails and the app shows "Couldn't save. Retry" from the root toaster, which uses popover="manual". What happens? Select all that apply.
Architecture, data model, and props
Time to put the pieces on one page. The system has a plain JavaScript core (the API and the store) and a thin React layer (the toaster and its parts). The boundary sits exactly where "callable from anywhere" meets "rendered in one place".
Toaster subscribes, so a new toast re-renders the toaster and nothing else.| Part | Lives in | Responsibility |
|---|---|---|
toast() API | Plain module | Validates input, applies defaults (duration by severity and length), dedupes and rate limits, returns an id. |
| Toast store | Plain module | Holds the list, notifies subscribers, resets in tests. No timers, no DOM. |
| Timer controller | Inside Toaster | Starts, pauses, and resumes timers for visible toasts only; listens to hover, focus, and visibilitychange. |
Toaster | React, root layout | Subscribes with useSyncExternalStore, picks visible versus queued, owns the top layer popover and the hotkey. |
ToastItem | React | One toast: icon, text, action, close button, swipe handling, enter and exit animation. |
| Announcer | React, inside Toaster | Two empty live regions from first render; writes one short message per new or merged toast. |
Why do timers live in the toaster and not the store? Because pausing depends on the screen: hover, focus, and whether the tab is visible. A store with no toaster mounted (a test, or the moment before hydration) shouldn't run timers that expire toasts nobody saw.
Why is dedupe in the API and not the toaster? Because it must happen before a toast enters the store, so a burst never causes 20 renders.
onRemove(reason): the toast never calls the server, the caller does.Data model
type ToastType = "default" | "success" | "info" | "warning" | "error" | "loading";
type RemoveReason = "timeout" | "action" | "close" | "replaced";
type ToastRecord = {
id: string;
toasterId: string; // "default" unless routed
type: ToastType;
message: string | ReactNode; // strings render as text, never HTML
description?: string;
action?: { label: string; onClick: () => void };
key: string; // type + message, for merging
count: number; // 1, or more after merges
createdAt: number;
durationMs: number; // Infinity = until dismissed
remainingMs: number;
state: "queued" | "visible" | "paused" | "exiting";
announce: string; // text for the live region
onRemove?: (reason: RemoveReason) => void;
};
Note what isn't in the record: position and the visible limit. Those belong to a toaster, not a toast, so they are props. And remainingMs is in the record so that a toast moving between toasters, or being paused and resumed many times, never loses track of its time.
Toaster props
type ToasterProps = {
id?: string; // for routing, default "default"
position?: "top-left" | "top-center" | "top-right"
| "bottom-left" | "bottom-center" | "bottom-right";
maxVisible?: number; // default 3
expand?: boolean; // always expanded, no collapsed stack
hotkey?: string; // default "F8"
label?: string; // default "Notifications"
gap?: number; // px between expanded toasts, default 12
durations?: Partial<Record<ToastType, number>>;
pauseWhenHidden?: boolean; // default true
dir?: "ltr" | "rtl" | "auto";
};
Check your understanding · Exercise 10
Which responsibilities belong in the plain JavaScript core rather than in the React toaster? Select all that apply.
Running it in the interview
In 45 minutes you can't cover everything in this guide, and you don't need to. Show that you see the hidden problems, pick a few to go deep on, and keep a working design on the board at all times.
| Minutes | What to do | What to draw or write |
|---|---|---|
| 0 to 5 | Scope questions: who calls it, severities, actions, max on screen, SSR, accessibility target. | A short list of assumptions. |
| 5 to 12 | The API: why imperative, the id, update, dismiss, promise. | toast.success(...) returning an id; toast.promise. |
| 12 to 20 | The store and the toaster: external store, useSyncExternalStore, mount at the root. | The design diagram: callers, API, store, toaster, live regions. |
| 20 to 30 | Queue and timing: max 3, queue, durations by severity, pause on hover, focus, hidden tab. | The lifecycle state machine. |
| 30 to 38 | Accessibility: pre-mounted live regions, polite versus assertive, no focus stealing, hotkey. | Two hidden regions next to the visible list. |
| 38 to 45 | Undo, layering above dialogs, swipe, SSR, then questions. | The Undo sequence with its failure path. |
Say your trade-offs out loud. "I'm choosing an imperative API because toasts are events and the API client must call it; the cost is global module state, which I'll reset in tests." That one sentence earns more than five minutes of drawing a pretty toast.
Think first
Follow-up: "A WebSocket reconnect loop fires 50 'Connection lost' toasts per second. What happens in your design?"
Dedupe by content merges them into one toast with a count, and the rate limit caps new toasts at about 5 per second, folding the rest into a summary.
Better still, the socket layer should show one toast with a fixed id like connection and update it ("Reconnecting in 4 s"), then dismiss it on reconnect. A lasting state like "offline" may deserve a banner instead.
The live region announces the first message and one summary, not 50.
Think first
Follow-up: "Two micro-frontends on the page each bundle their own copy of the toast library. What breaks?"
Two module copies mean two stores and two toasters: toasts overlap in the same corner, the visible limit is per copy, and both live regions talk over each other.
Fix it by sharing one instance: the shell exposes the store on a well-known global or through module federation's shared singletons, and the other app calls that.
As a fallback, the library can detect an existing instance at startup (a symbol on globalThis) and reuse it instead of creating a second store.
Think first
Follow-up: "How do you test this?"
Unit tests for the core with fake timers: durations by severity, merge within the window, queue release order, pause keeps remaining time.
Component tests with Testing Library: a toast appears in the region, the action fires onRemove("action"), focus returns after F8 and Escape.
Accessibility checks with axe, plus a manual pass with VoiceOver and NVDA, because automated tools can't tell whether a live region was actually announced.
Check your understanding · Exercise 11
You have 5 minutes left and haven't covered accessibility. Which points give the most value if said briefly? Select all that apply.
Summary
- A toast is an event, so create it with an imperative
toast()that returns an id, backed by a tiny store outside React and rendered by oneToasterread throughuseSyncExternalStore. - Separate "in the store" from "on screen": show at most 3 in a collapsed stack, queue the rest, and start a toast's timer only when it becomes visible.
- Merge identical toasts and rate limit bursts before they reach the store or the screen reader.
- Duration depends on severity, length, and actions; pause on hover, focus, and hidden tabs; errors with actions stay until handled.
- For Undo, hide at once and commit when the toast ends without Undo, flushing on
pagehide, and restore with an error toast if the commit fails. - Announce through live regions that exist before the first toast: polite
statusby default, assertivealertonly for blocking errors. Never steal focus; offer a hotkey instead. - Use
popover="manual"for the layer, but remember that a modal dialog makes outside content inert, unclickable and silent to screen readers: route toasts into a toaster inside the dialog while it is open. - No toasts on the server: a constant empty server snapshot, live regions in the HTML, and one-time flash messages for toasts that cross a page load.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- useSyncExternalStore React docs: subscribing to an outside store, snapshot rules, and getServerSnapshot.
- ARIA live regions MDN: aria-live, polite versus assertive, and the status and alert roles.
- ARIA: alert role MDN: when an assertive announcement is right, and when it's too much.
- Understanding SC 2.2.1: Timing Adjustable W3C: what counts as a time limit and how users must be able to adjust it.
- Understanding SC 2.2.2: Pause, Stop, Hide W3C: moving and auto-updating content.
- Popover API MDN: popover="manual", the top layer, and showPopover.
- HTMLDialogElement: showModal() method MDN: modal dialogs make the rest of the page inert.
- Page Visibility API MDN: visibilitychange and pausing work in hidden tabs.
- prefers-reduced-motion MDN: honoring the user's motion setting.
Concepts behind it
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.