cf.completefrontend
My progress
SYSTEM DESIGN · #6

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

QuestionAnswer we assumeWhat 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.
Anatomy of a toast system in a mail app. The page has a navigation column and an inbox list. In the bottom-right corner a collapsed stack shows the newest toast in front, reading 3 emails archived, with an Undo button and a close button. Two older toasts peek out behind it, slightly smaller; hovering expands them. Notes explain that the region is a section labelled Notifications F8, and that two hidden, empty live regions are in the page from the start.mail app, bottom-right toasternavinbox list3 emails archivedUndoX2 older toastspeek; hover orfocus expandsaction: Undoclose: X button2 live regions:hidden, emptysince page loadsection: Notifications (F8)
The newest toast sits nearest the corner, and older ones peek out behind it. The parts you can't see matter as much: a labelled region you can jump to, and live regions that were in the page before the first toast.

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 transform and opacity only, 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

When a toast is the right tool

SituationRight toolWhy
"Saved" after an explicit saveToast (success)Confirms a result the user expects; safe to miss.
"3 emails archived. Undo"Toast with actionShort window to reverse an action; the app keeps working meanwhile.
Form field is invalidInline error next to the fieldThe message belongs to a place on the page, not to the corner.
"Delete this repository?"Modal dialogThe user must decide before anything happens.
Offline for the last 10 minutesPersistent bannerA lasting state, not an event. It stays until the state changes.
New message from AlexNotification center plus a toastThe 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

tsx
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>
    </>
  );
}
Declarative toast: every caller owns state and markup.

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?

Declarative component per caller

Each caller renders a Toast with open state.

Position and stacking are left to CSS or a shared container.

Pros

  • Familiar React style.
  • Easy to test one toast in isolation.

Cons

  • Toast dies when the caller unmounts.
  • No global queue, limit, or dedupe.
  • Unusable from non-React code.

Hook that returns a show function

const { show } = useToast() reads a context set by a provider.

show() pushes into state held by the provider.

Pros

  • One central list, so a limit and order are possible.
  • Toasts outlive the caller.

Cons

  • Still only usable inside components under the provider.
  • Every show() updates context, which can re-render every consumer.

Imperative function plus external store

toast("Saved") is a plain function exported by a module.

It writes to a tiny store outside React.

One Toaster component subscribes and renders.

Pros

  • Callable from anywhere: components, API clients, sockets, tests.
  • Only the toaster re-renders.
  • Returns an id for updates and dismissal.

Cons

  • Global module state; needs care in tests and with multiple toasters.
  • Less "React-like" for teams used to props.

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

ts
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}`,
});
The public API. Every call returns an id.

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.

Sequence diagram of toast.promise for a save that fails. Lifelines: UI, toast(), Store, Toaster, and API. The UI calls toast.promise with the save promise. toast() adds a loading toast to the store. The store notifies the toaster, which renders Saving. Meanwhile the UI's save sends PUT /draft to the API. The API replies 500. toast() sees the promise reject and updates the same toast id to an error. The store notifies the toaster, which renders Couldn't save with a Retry button and announces it through the alert region.UItoast()StoreToasterAPItoast.promiseadd loadingSaving...PUT /draft500 errorupdate(error)error, Retry
One id from start to end: the loading toast becomes the error toast, so the user sees one box change instead of two boxes. 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?

React context in a provider

A ToastProvider holds useState with the list.

useToast() reads the setter from context.

Pros

  • No global variables; each provider is isolated, which helps tests.
  • Plain React, easy to read.

Cons

  • Not reachable from outside React without hacks like storing the setter in a module variable.
  • A context value change re-renders every consumer of that context.

The app's global store (Redux, Zustand)

Toasts become a slice; toast() dispatches an action.

Pros

  • Devtools and time travel for free.
  • Reachable from middleware and thunks.

Cons

  • Couples a design-system component to one app's store choice.
  • Other apps using the design system may not have that store.

Own tiny external store plus useSyncExternalStore

A module holds toasts and a Set of listeners.

Writes replace the array and notify listeners.

Toaster reads it with React's useSyncExternalStore.

Pros

  • Reachable from any code, about 40 lines, no dependency.
  • React handles subscription and tearing safety in concurrent rendering.
  • Works with or without a provider.

Cons

  • Module-level state is shared by everything in the page, so tests must reset it.
  • Multiple toasters need explicit routing.

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

ts
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[] = [];
The whole store. Every write creates a new array, so the snapshot changes only when data changes.

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.

tsx
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} />;
}
The toaster subscribes. Only this component re-renders when toasts change.

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?

Drop the oldest

Keep the newest N; remove the rest immediately.

Pros

  • Simple. The screen always shows the latest news.

Cons

  • Messages are lost before anyone sees them, including errors.

Queue, show N, release as space frees

Show the first N in arrival order.

The rest wait; their timers don't start until they are visible.

Pros

  • Nothing is lost.
  • Each toast gets its full reading time.

Cons

  • Under a long burst the queue can hold stale messages that show up a minute late.

Collapse into a stack, show the newest on top

All toasts render, but only the newest is fully visible; older ones peek out behind it, scaled down.

Hover or focus expands the stack.

Pros

  • Compact: about one toast's height.
  • Everything is reachable on demand.

Cons

  • Older toasts are partly hidden, so their timers must be fair.
  • More layout and animation work.

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.

Two states of the same toast stack. On the left, collapsed: the newest toast, Saved draft, is in front, and two older toasts peek out behind it, smaller. On the right, after hovering, the stack is expanded: Upload done at the top, Link copied in the middle, and Saved draft, the newest, at the bottom near the corner. While expanded, all timers are paused. Below, two more toasts wait in the queue with no timer running.collapsed (idle)Saved draftnewest in fronthoverexpanded (hover)Upload doneLink copiedSaved draftall timers pausedqueue: 2 waiting, no timer
Collapsed, the stack takes about one toast of height. Hover or focus spreads it out and pauses every timer. Toasts beyond the visible limit wait in the queue, and their clocks haven't started.

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.

PositionGrows towardGood forWatch out for
bottom-right (default)UpDesktop apps; away from main navigationChat widgets and cookie banners in the same corner
bottom-centerUpPhones (full width minus 16 px margins)The on-screen keyboard and bottom nav bars; respect env(safe-area-inset-bottom)
top-centerDownShort status like "Copied"Covers headers and search boxes
top-right, top-left, bottom-leftAway from the edgeApps whose other corners are busyRight-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.

Max visible
Toaster at 0 seconds, max visible 3. Visible: none. Queued: none. Clock running.visible (max 3)queue: 0 waitingclock: 0 s, runningmerge duplicates: on
Fire some toasts, then step the clock.

Deduplication and rate limiting

Even with a queue, 20 copies of the same error are useless. Three layers stop them, from cheapest to smartest:

  1. 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.
  2. 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.
  3. 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.
ts
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;
}
Merge by content inside add(). The key is severity plus text.

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

KindDefault durationWhy
success, info4 s, plus reading time for long textConfirms an expected result; safe to miss.
warning6 s, plus reading timeSomething the user may want to act on later.
loadingNo timerIt's replaced by success or error when the work ends.
error without action8 s, plus reading timeImportant, but there is nothing to do in the toast.
any toast with an action (Undo, Retry)At least 6 s; errors with actions: no timerReaching 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 focusin and focusout on the region.
  • Tab hidden. When document.visibilityState is hidden (the visibilitychange event), pause everything. The user can't read a tab they aren't looking at.
  • Window not focused (optional). On blur, pause; on focus, 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.

ts
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;
}
Pause and resume keep the remaining time instead of restarting.

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.

A timeline from 0 to 14 seconds with three rows. Fixed 3 s: the toast is shown from 0 to 3 seconds, then gone, and the user looks back at 5 seconds and finds nothing. Hover: a 5 second toast runs for 3 seconds, is paused for 5 seconds while hovered, then runs its last 2 seconds and exits at 10 seconds. Hidden tab: the toast runs 2 seconds, is paused for 6 seconds while the tab is hidden, then restarts with a full 5 seconds.fixed 3 sshowngoneuser looks back at 5 shoverrunpausedrunexit at 10 shidden tabrunhiddenrun 5 s0 s4 s8 s12 s
Paused time doesn't count. After a hover the toast continues with what was left; after a hidden tab it restarts, because the user may have forgotten it entirely.

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.

State machine of one toast. Queued, with no timer, goes to visible when a slot is free. Visible, with its timer running, goes to exiting on timeout, close, or an action. Visible goes to paused on hover, focus, or a hidden tab, and paused returns to visible on resume. Paused keeps its remaining time and can also go to exiting on close or an action. Exiting runs a 200 millisecond animation, then goes to removed, which calls onRemove with the reason. A note says that a toast queued for more than 30 seconds is dropped unless it is an error.queuedno timervisibletimer runsexiting200 ms animpausedtime keptremovedonRemove(reason)slot freetimeout, close, actionhover, focus, hiddenresumeclose, actiondonequeued over 30 s:dropped, unlessit's an error
Exiting is its own state, so a late timer or an update can't revive a toast that is already leaving. 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?

Delete now, undo with a second request

Send DELETE immediately.

Undo sends a restore request, such as POST /emails/restore.

Pros

  • The server is always up to date; other devices see the change at once.
  • Nothing is lost if the tab closes.

Cons

  • Needs a restore endpoint and soft deletes on the server.
  • Side effects (emails sent, webhooks fired) may not be reversible.

Hide now, commit when the toast expires

Remove the items from the UI at once (optimistic).

Start the request only when the toast closes without Undo.

Undo just puts the items back; no request at all.

Pros

  • Undo is instant and always works.
  • No restore endpoint needed; works for irreversible actions.

Cons

  • Closing the tab during the window must still commit.
  • Other devices see the change seconds later.

Confirm dialog first, no undo

Ask "Delete 5 emails?" before doing anything.

Pros

  • Simple; nothing pending.

Cons

  • Adds a click to every delete; users learn to click Yes without reading.
  • Interrupts flow for a reversible action.

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.

Sequence diagram of archive with Undo, including the failure path. Lifelines: User, Mail UI, Toast, and API. The user archives 5 emails. The Mail UI hides the rows at once and calls toast with an Undo action. The toast shows 5 archived, Undo to the user. The user doesn't click Undo, so when the timer ends the toast calls onRemove with reason timeout. Only now does the Mail UI send POST /archive to the API. The API replies 503. The Mail UI unhides the rows and shows an error toast with Retry, which tells the user Couldn't archive, Retry.UserMail UIToastAPIarchive 5 emailshide, toast(Undo)5 archived. UndoonRemove(timeout)POST /archive503unhide, error toastCouldn't archive
No request leaves until the toast ends without Undo. If the user clicks Undo, the rows come back and nothing is sent. If the late commit fails, the rows return and an error toast with Retry stays until handled.

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".

ts
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) },
        });
      }
    },
  });
}
The caller hides at once and decides on commit or rollback when the toast ends.

Edge cases

  • The user closes the tab during the window. Listen to pagehide (more reliable than beforeunload, and it also fires when the page enters the back/forward cache) and flush pending commits with fetch(url, { method: "POST", keepalive: true }) or navigator.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 when visibilitychange reports hidden, 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?

role=alert on every toast

Each new toast element carries role="alert".

Pros

  • One line of code.

Cons

  • Every message interrupts.
  • Relies on special handling of inserted alerts, which varies by browser and screen reader.
  • A stack of 3 may be re-read when the list re-renders.

The visible list is the live region

An ol with aria-live="polite" exists from page load; toasts are added as li items.

Pros

  • Simple; what you see is what is read.
  • Region exists before the first toast.

Cons

  • Buttons and counters inside the list are read too ("Undo, button, close, button").
  • Updating a counter or progress re-announces.

Two hidden announcer regions, separate from the visible list

Two empty, visually hidden regions are rendered at mount: one role="status" (polite), one role="alert" (assertive).

On each new toast, the toaster writes a short plain-text message into the right one.

Pros

  • Full control over what is read, and how often.
  • Merged or updated toasts don't re-announce unless we choose to.

Cons

  • Two places to keep in sync.
  • Must clear and re-set text to repeat a message.

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.

RegionRole
A live region with role status, polite, already in the page. The screen reader is idle. Heard so far: nothing.role=statusaria-live=politeregion existsreader idlescreen reader heardnothing yetmerge duplicates: on
Show a toast and watch what the screen reader says.

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?

Portal into body with a huge z-index

createPortal into document.body, position: fixed, z-index: 9999.

Pros

  • Works in every browser.
  • Escapes overflow: hidden parents.

Cons

  • z-index wars with other libraries.
  • Always below the top layer, so a native modal dialog covers it.

Top layer with popover="manual"

The toaster root is an element with popover="manual" and showPopover() is called when the first toast arrives.

Manual popovers don't light-dismiss and don't close other popovers.

Pros

  • No z-index at all; the top layer paints above the page.
  • Opened after a dialog, it paints above that dialog.
  • Supported in all major browsers since 2024.

Cons

  • Top layer order is by opening time: a dialog opened later covers it unless we re-show it.
  • Still inert, and silent to screen readers, while a modal dialog is open (see below).

Toaster moves into the topmost open dialog

The toaster renders in the page normally, but when a modal dialog is open, its region is portalled inside that dialog.

Pros

  • Toasts stay clickable while the dialog is open.

Cons

  • Needs to know which dialog is on top; the toast list jumps containers.
  • Live region nodes move, which can drop an announcement.

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).

Two stacks of layers, bottom to top. Left, a portal with z-index 9999: the page, then the toaster, then the modal dialog in the top layer, which covers the toast. Right, popover manual: the page, then the modal dialog in the top layer, then the toaster popover, which entered the top layer later and paints above the dialog, but is inert because it is outside the dialog: clicks are ignored and screen readers can't reach it.portal, z-index 9999modal dialogtop layertoaster, z 9999pagetoast hidden under dialogpopover=manualtoaster popovertop layer, opened latermodal dialogtop layer, opened firstpageon top, but inert
No z-index beats the top layer. A 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. getServerSnapshot returns 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 status and alert regions 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 is display: 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-in that the app reads on load, shows, and then removes with history.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".

Architecture of the toast system in three zones. Callers: Components, API client, and Sockets all call the toast API. Core, plain JavaScript: the toast API applies defaults and dedupe, then writes to the Store, which holds the list and listeners. React, in the root layout: the Toaster subscribes to the store with useSyncExternalStore and picks visible and queued toasts. It drives Timers, which pause on hover and hidden tabs, and ToastItems, rendered in a manual popover with swipe. It also feeds the Announcer, the status and alert live regions.callersComponentsAPI clientSocketscore, plain JStoast() APIdefaults, dedupeStorelist, listenersReact, root layoutToasteruseSyncExternalStoreTimershover, hiddenToastItempopover, swipeAnnouncerstatus and alert regionswritenotify
The core has no React and no DOM, so any code can call it. Only the Toaster subscribes, so a new toast re-renders the toaster and nothing else.
PartLives inResponsibility
toast() APIPlain moduleValidates input, applies defaults (duration by severity and length), dedupes and rate limits, returns an id.
Toast storePlain moduleHolds the list, notifies subscribers, resets in tests. No timers, no DOM.
Timer controllerInside ToasterStarts, pauses, and resumes timers for visible toasts only; listens to hover, focus, and visibilitychange.
ToasterReact, root layoutSubscribes with useSyncExternalStore, picks visible versus queued, owns the top layer popover and the hotkey.
ToastItemReactOne toast: icon, text, action, close button, swipe handling, enter and exit animation.
AnnouncerReact, inside ToasterTwo 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.

Data flow of a toast end to end. The API server sends a 500 JSON response to the app code, which catches it and passes a message to toast(), which dedupes and writes a record to the Store. The Store hands a snapshot to the Toaster, which renders the Toast DOM in a manual popover, which the user sees, and writes text to the live region, which the screen reader announces. Going back, an Undo or close in the Toast DOM calls onRemove with the reason in the app code, which may send POST /archive to the API server.API serverRESTApp codefetch, catchtoast()dedupeStorelist500 JSONPOST /archivemessagerecordToastervisible, queuesnapshotToast DOMpopover=manualrenderuserseesonRemove(reason)Live regionstatus, alerttextScreen readerpolite or assertiveannounce
Data goes one way, from the API to the screen and the screen reader. The only way back is onRemove(reason): the toast never calls the server, the caller does.

Data model

ts
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;
};
One record per toast. Everything here is in memory; nothing is persisted.

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

ts
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";
};
Layout is declarative; creation is imperative.

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.

MinutesWhat to doWhat to draw or write
0 to 5Scope questions: who calls it, severities, actions, max on screen, SSR, accessibility target.A short list of assumptions.
5 to 12The API: why imperative, the id, update, dismiss, promise.toast.success(...) returning an id; toast.promise.
12 to 20The store and the toaster: external store, useSyncExternalStore, mount at the root.The design diagram: callers, API, store, toaster, live regions.
20 to 30Queue and timing: max 3, queue, durations by severity, pause on hover, focus, hidden tab.The lifecycle state machine.
30 to 38Accessibility: pre-mounted live regions, polite versus assertive, no focus stealing, hotkey.Two hidden regions next to the visible list.
38 to 45Undo, 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?"

Think first

Follow-up: "Two micro-frontends on the page each bundle their own copy of the toast library. What breaks?"

Think first

Follow-up: "How do you test this?"

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 one Toaster read through useSyncExternalStore.
  • 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 status by default, assertive alert only 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next