SuperAPI AI Directory

frontend-component-modal-toast-system

Frontend & websites

View repository

Updated 10-15 22:18

About this skill

Frontend Component : Modal + Toast System

This skill is the operational reference for building accessible modals and toast notifications using native browser primitives only : the <dialog> element with showModal() for modals, and the Popover API plus an aria-live region for toasts. It covers focus restoration, scroll lock, the live-region urgency mapping, toast queue management, and the @starting-style animation pattern. The skill does NOT cover the popover / dialog API mechanics deep-dive (see [[frontend-impl-popover-dialog-anchor]]), the ARIA roles deep-dive (see [[frontend-a11y-aria-patterns]]), or custom focus-trap libraries (use showModal() instead).

Quick Reference

Floor rules

  • ALWAYS use <dialog> with dialog.showModal() for modals. NEVER roll a custom focus-trap JS ; showModal() traps focus, inerts the rest of the document, enables Escape-close, and places the dialog on the top layer for free.
  • ALWAYS capture the triggering element before opening a modal AND restore focus to it on the close event. The browser does NOT restore focus automatically.
  • ALWAYS use aria-live="polite" for status, success, and informational toasts. NEVER use aria-live="assertive" for non-urgent updates ; it interrupts the screen reader mid-sentence.
  • ALWAYS use popover="manual" for stacked toasts. NEVER use popover="auto" ; auto popovers are mutually exclusive in the top-layer stack so a second toast silently replaces the first.
  • ALWAYS create the aria-live region in the DOM BEFORE inserting the message. NEVER create the region and the message in the same DOM mutation ; the screen reader observes mutations on a pre-existing region.
  • ALWAYS pair <dialog> with aria-labelledby (pointing to the title) OR aria-label. NEVER ship a modal without an accessible name.
  • ALWAYS use dialog.close(value) and dialog.returnValue for confirm-style dialogs. NEVER hand-roll a Promise wrapper unless you also need to bridge to async / await.
  • NEVER set tabindex on a <dialog> element. MDN forbids it ; the dialog manages its own tabindex.

Decision tree 1 : <dialog> showModal versus popover ?

The UI must INTERRUPT and demand a response (confirm destructive action,
sign-in flow, settings overlay) ?
  -> <dialog> with dialog.showModal(). Modal : blocks the rest of the page,
     traps focus, Escape closes, ::backdrop styles the dim overlay.

The UI is a NON-BLOCKING surface (menu, tooltip, autocomplete listbox,
date picker, share sheet) ?
  -> Popover. Use popover="auto" for mutually-exclusive UI (one menu at a time)
     OR popover="manual" for independent surfaces (toast queue).

The UI is a TOAST notification (transient status feedback) ?
  -> Popover with popover="manual" + aria-live region paired with the message
     element. The popover provides top-layer positioning ; the aria-live
     region provides the announcement.

You need a hover-revealed surface (tooltip) ?
  -> Popover with popover="hint". Lower-priority than auto, can coexist with
     other auto popovers.

Decision tree 2 : Toast urgency : polite or assertive ?

The toast reports a STATUS or SUCCESS that the user expected
("Saved", "Copied to clipboard", "Sent") ?
  -> aria-live="polite". The screen reader announces when it reaches a
     natural pause. Does not interrupt.

The toast reports an ERROR or WARNING that the user did not initiate
("Connection lost", "File failed to upload", "Session expired") ?
  -> aria-live="assertive" OR role="alert" (which implies
     aria-live="assertive" + aria-atomic="true"). The screen reader
     interrupts to announce.

The toast announces a periodic background poll
("Weather updated", "Inbox refreshed every 30 s") ?
  -> aria-live="polite". Periodic noise is exactly what polite is for.
     NEVER use role="alert" for periodic updates ; it spams the user.

The toast contains both a heading and a body ?
  -> aria-atomic="true" on the live region so the announcer reads the
     full toast as one unit instead of just the changed nodes.

Decision tree 3 : Persistent or transient dismissal ?

Auto-dismiss after N seconds, pause on hover or focus ?
  -> setTimeout-based timer. Clear timer on `pointerenter` and `focusin`;
     restart on `pointerleave` and `focusout`. Recommended duration :
     4 to 7 seconds for short messages ; 7 to 12 seconds when there is an
     action button (so the user has time to read AND click).

Manual dismiss only (the toast carries an action the user must take) ?
  -> popover="manual" + an explicit close button (popovertargetaction="hide")
     and / or an action button. No timer.

Toast is a confirmation requiring an undo within a time budget ?
  -> Auto-dismiss with progress indicator. The progress bar gives the user
     visual feedback on how much time remains. Pause on hover.

Toast is critical and must NOT auto-dismiss (security warning) ?
  -> Use a modal `<dialog>` instead. Toast is the wrong primitive for
     critical, must-acknowledge messages.

Patterns

Pattern : Confirm modal with <dialog> + return value

<button type="button" data-open="confirm">Delete account</button>

<dialog id="confirm" aria-labelledby="confirm-title" aria-describedby="confirm-body">
  <h2 id="confirm-title">Delete account ?</h2>
  <p id="confirm-body">This permanently removes all your data.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm" autofocus>Delete</button>
  </form>
</dialog>
const dialog = document.querySelector("#confirm");
const opener = document.querySelector('[data-open="confirm"]');
let trigger = null;

opener.addEventListener("click", () => {
  trigger = opener;
  dialog.showModal();
});

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "confirm") deleteAccount();
  trigger?.focus();
  trigger = null;
});

<form method="dialog"> is the platform-native way to close on submit and pass the submitter's value to dialog.returnValue. No JS submit handler needed.

Pattern : Modal with animated open / close

dialog {
  border: 1px solid oklch(0.85 0 0);
  border-radius: 12px;
  padding: 2rem;
  opacity: 1;
  transform: translateY(0);
  transition: opacity 200ms, transform 200ms, overlay 200ms allow-discrete, display 200ms allow-discrete;
}

dialog:not([open]) {
  opacity: 0;
  transform: translateY(8px);
}

@starting-style {
  dialog[open] {
    opacity: 0;
    transform: translateY(8px);
  }
}

dialog::backdrop {
  background: oklch(0.10 0 0 / 0);
  transition: background 200ms, display 200ms allow-discrete, overlay 200ms allow-discrete;
}

dialog[open]::backdrop {
  background: oklch(0.10 0 0 / 0.4);
}

@starting-style {
  dialog[open]::backdrop {
    background: oklch(0.10 0 0 / 0);
  }
}

transition-behavior: allow-discrete (declared via the allow-discrete keyword in the transition shorthand) lets display and overlay participate in the transition so the dialog can animate from / to display: none. @starting-style provides the entry baseline.

Pattern : Toast queue (popover manual + aria-live)

<div id="toast-queue" class="toast-stack" aria-live="polite" aria-atomic="false"></div>
const queue = document.querySelector("#toast-queue");
const MAX_VISIBLE = 4;

function showToast({ text, urgent = false, duration = 5000, action }) {
  if (queue.children.length >= MAX_VISIBLE) {
    queue.firstElementChild.remove();
  }

  const toast = document.createElement("div");
  toast.className = "toast";
  toast.setAttribute("popover", "manual");
  if (urgent) toast.setAttribute("role", "alert");
  else toast.setAttribute("role", "status");

  const body = document.createElement("p");
  body.textContent = text;
  toast.append(body);

  if (action) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = action.label;
    btn.addEventListener("click", () => { action.run(); toast.hidePopover(); });
    toast.append(btn);
  }

  const close = document.createElement("button");
  close.type = "button";
  close.setAttribute("aria-label", "Dismiss");
  close.textContent = "×";
  close.addEventListener("click", () => toast.hidePopover());
  toast.append(close);

  queue.append(toast);
  toast.showPopover();

  let timer = setTimeout(() => toast.hidePopover(), duration);
  toast.addEventListener("pointerenter", () => clearTimeout(timer));
  toast.addEventListener("focusin", () => clearTimeout(timer));
  toast.addEventListener("pointerleave", () => { timer = setTimeout(() => toast.hidePopover(), duration); });
  toast.addEventListener("focusout", () => { timer = setTimeout(() => toast.hidePopover(), duration); });

  toast.addEventListener("toggle", (e) => {
    if (e.newState === "closed") toast.remove();
  });
}

popover="manual" is critical : without it the second toast silently hides the first because popover="auto" enforces top-layer mutual exclusivity. The live region exists BEFORE the message is inserted, so the screen reader observes the mutation and announces.

Pattern : Focus restoration

function openModal(dialog, trigger) {
  dialog.dataset.opener = trigger.id || "";
  dialog.showModal();
}

function attachRestore(dialog) {
  dialog.addEventListener("close", () => {
    const id = dialog.dataset.opener;
    if (id) document.getElementById(id)?.focus();
    delete dialog.dataset.opener;
  });
}

The browser does NOT restore focus when <dialog> closes. Capture the trigger before opening and restore in the close event.

Pattern : Light-dismiss for non-critical modals

<dialog id="settings" closedby="any">
  ...
</dialog>

closedby="any" allows light-dismiss on backdrop click PLUS Escape. Values : any, closerequest (Escape only), none (programmatic close only). Use any for settings / detail overlays, closerequest for destructive confirms.

Live-region behavior summary

Pattern Implicit ARIA Announces when Use for
aria-live="polite" live region with polite priority at next pause status, success, info
aria-live="assertive" live region with assertive priority immediately, interrupts errors, time-critical
role="status" implies aria-live="polite" + aria-atomic="true" at next pause, full text toast wrappers for status
role="alert" implies aria-live="assertive" + aria-atomic="true" immediately errors, security warnings
aria-atomic="true" n/a full live region read as one unit multi-part toasts

Source : MDN : ARIA Live Regions (verified 2026-05-19), W3C WAI APG : Alert (verified 2026-05-19).

Cross-references

  • [[frontend-impl-popover-dialog-anchor]] : full <dialog> and Popover API mechanics, anchor positioning, closedby attribute.
  • [[frontend-a11y-aria-patterns]] : Dialog Modal pattern, Alert pattern, full ARIA role surface.
  • [[frontend-a11y-focus-keyboard-inert]] : focus management, :focus-visible, inert attribute, keyboard interaction.
  • [[frontend-visual-micro-interactions]] : hover, focus, press transitions for action buttons.
  • [[frontend-a11y-motion-contrast-wcag22]] : prefers-reduced-motion gate for entry / exit animations.

Reference Links

  • references/methods.md : HTMLDialogElement interface, Popover API surface, aria-live semantics, @starting-style and transition-behavior: allow-discrete.
  • references/examples.md : renderable HTML demo with confirm modal, toast queue, animated entry / exit, all keyboard-navigable.
  • references/anti-patterns.md : nine anti-patterns with symptom, root cause, and fix.

Authoritative sources