RamosLabs DS

Patterns / Modals

A modal steals the whole room.

It stops the task, blanks the background, and traps the keyboard until the user answers. That power is worth spending only on the rare moment that deserves it, and when you spend it the accessibility has to be flawless. So the rule is short. Reach for the native <dialog> with showModal() first, use it rarely, and never open one where a page, an inline message, or a popover would have done the job without taking the room.

1

The six things a modal must do

An accessible modal dialog is not a styled overlay. It is a contract with the keyboard and the screen reader, and the WAI-ARIA Authoring Practices name six terms of it. Miss any one and the modal is broken for someone. The good news is in Section 2: the native element hands you all six.

1It has an accessible name

Point aria-labelledby at the visible title, or set aria-label when there is no visible title. Without a name it fails WCAG 4.1.2.

APG, Dialog Pattern. The dialog has "a value set for the aria-labelledby property that refers to a visible dialog title."

2Focus moves in on open

When the dialog opens, focus lands on an element inside it, not out on the page behind. Use autofocus to place it deliberately.

APG, Dialog Pattern. "When a dialog opens, focus moves to an element inside the dialog."

3Tab cycles inside

Tab off the last control wraps to the first, and Shift+Tab off the first wraps to the last. Focus never escapes to the inert page.

APG, Dialog Pattern. On the last tabbable element, Tab "moves focus to the first tabbable element inside the dialog."

4Escape closes it

The Escape key dismisses the dialog. This is the release valve that keeps the focus trap legal under WCAG 2.1.2 (see Section 6).

APG, Dialog Pattern. "Escape: Closes the dialog."

5Focus returns to the trigger

On close, focus goes back to the control that opened the dialog, so the user resumes where they left off rather than at the top of the page.

APG, Dialog Pattern. "Focus returns to the element that invoked the dialog."

6The background is inert

Everything behind the dialog leaves the tab order and the accessibility tree. A pointer, a Tab, and a screen reader all stop at the dialog edge.

APG, Dialog Pattern. "Windows under a modal dialog are inert... users cannot interact with content outside an active dialog window."

2

The native element hands you all six

<dialog> opened with .showModal() gives you the whole contract for free and correctly: focus moves in, Tab is trapped, Escape closes, the background goes inert, and focus returns to the trigger on close. It renders in the top layer, so it sits above the page with no z-index fight, and it exposes a ::backdrop to dim what is behind. Building this by hand means re-deriving every one of those behaviors, and that is exactly where accessibility bugs are born.

MDN, in its own words. "While dialogs can be created using other elements, the native <dialog> element provides usability and accessibility features that must be replicated if you use other elements for a similar purpose."

Open the demo below. Tab through it and the focus stays inside. Press Escape and it closes. Notice the dimmed ::backdrop. The Cancel and Save buttons live in a <form method="dialog">, so activating either closes the dialog and reports its returnValue without a submit or a page reload. There is a real close button too, because Escape alone is not a discoverable exit.

Live: role dialog, content and form

Opens with showModal(). Initial focus lands on the text field via autofocus. Tab is trapped, Escape closes, focus returns to this button.

Rename event

Give this event a clearer public name. The change is visible to attendees right away.

What showModal() gives you for free
Background inertFocus entersTab trappedEscape closesTop layer::backdrop

Three rules from MDN keep it honest: never open a modal with the open attribute (that makes a non-modal, no trap, no Escape, no inert), never put tabindex on the <dialog>, and always ship an explicit close button.

HTML
<!-- Open with .showModal(), never the open attribute. -->
<button onclick="dlg.showModal()">Rename event</button>

<dialog id="dlg" aria-labelledby="dlg-title">
  <!-- method="dialog" closes on submit, no reload, sets returnValue -->
  <form method="dialog">
    <h3 id="dlg-title">Rename event</h3>
    <input autofocus value="Summer Rooftop Session">
    <button value="cancel">Cancel</button>
    <button value="save">Save name</button>
  </form>
</dialog>

Animating opts you out of one guarantee. web.dev warns that when you animate a dialog open or closed, the built-in focus return is lost and has to be restored by hand with event listeners. This demo does not animate, which is why its focus snaps back to the trigger on its own. If you add motion, put the focus return back yourself, and honor prefers-reduced-motion.

3

dialog for content, alertdialog for consequences

The role is not decoration. role="alertdialog" tells assistive tech this dialog interrupts to demand a response, so it can treat it specially, a system alert sound for instance. Use it for confirmations and destructive or irreversible actions. Use plain dialog for forms, selection, and content. The decision rule is one line.

role dialog

Forms, pickers, multi-step content. Nothing is destroyed by opening it. Focus the first field or the primary control.

role alertdialog

Delete, cancel, wipe, anything irreversible. Requires aria-describedby on the message, and focus starts on Cancel.

An alertdialog carries every requirement from Section 1 plus two of its own: aria-describedby must point at the element holding the alert message, and the safest initial focus is the Cancel button, not the destructive one. Putting focus on Cancel makes the dangerous choice deliberate rather than one stray Enter away. Open the demo and press Enter the instant it appears: nothing is destroyed, because Cancel is what was focused.

Live: destructive confirmation

Opens as role="alertdialog" with autofocus on Cancel. The red is the --color-error family, the only place this system leaves indigo.

Delete this event?

This permanently removes Summer Rooftop Session and its 240 sold tickets. This cannot be undone.

Why the extra role earns its keep

The APG is blunt about the reason: "The alertdialog role enables assistive technologies and browsers to distinguish alert dialogs from other dialogs so they have the option of giving alert dialogs special treatment, such as playing a system alert sound."

NN/g draws the same line from the UX side: interrupt the user precisely when "there is a chance that users' work be lost or that an action may have destructive, irreversible consequences." That is the alertdialog's whole job. A purely informational message that needs no decision should probably not be a modal at all (Section 4).

4

When not to use a modal at all

A modal charges a tax. NN/g lists the bill: it demands immediate action, breaks the flow, makes the user lose their place, adds cognitive load to recover the task, and hides relevant background. "Modal dialogs that are not directly related to users' goals are perceived as annoying and can diminish trust." Before you open one, walk down this ladder and stop at the first rung that fits. Most of the time you stop before the modal.

1

A separate page. For anything complex or multi-step. This is GOV.UK's default: their "one thing per page" philosophy sends the user to another page instead of a modal, and their public design system ships no modal component at all. Simplicity over speed.

2

Inline content. A field failed validation? Show the error inline, next to the field. Help text, hints, and single-field errors belong in the page, never behind an interruption.

3

A non-modal dialog. When the user should keep working with the background, like a compose window, open a dialog with show() instead of showModal(). No trap, no inert, the page stays live around it.

4

A popover. Action menus, toasts, form suggestions, teaching hints. Lightweight, non-modal, light-dismiss. This is Section 5.

Never modal. Newsletter and promo interrupts unrelated to the goal, high-risk checkout (NN/g advises against it explicitly), decisions that need information the user cannot see inside the modal, and single-field validation errors. If the content does not deserve to steal the room, it does not get a modal.

5

Popover for everything non-modal

The Popover API is the tool for overlays that must not steal the room. "Popovers created using the Popover API are always non-modal." They do not make the background inert, they do not trap focus, and the rest of the page stays interactive. MDN draws the boundary cleanly: "If you want to create a modal popover, a <dialog> element is the right way to go." A popover="auto" light-dismisses on Escape or an outside click and wires up with nothing but popovertarget on a button.

Live: non-modal action menu

Wired with popovertarget alone. Click outside or press Escape to light-dismiss. The page behind stays fully interactive, no trap, no inert. It sits below the trigger via CSS anchor positioning, with a centered fallback where that is unsupported.

Tooltip vs popover vs dialog

Tooltip is a brief description on hover or focus over a control. It takes no focus of its own and holds no interactive content. Not a dialog.

Popover is a light interactive layer. The background stays active, dismiss is light, focus is not trapped. Menus, toasts, pickers.

Dialog, modal interrupts. Background inert, focus trapped, needs an answer. Spend it rarely.

The menu above sits at its trigger with pure CSS anchor positioning, no measuring script. Name the trigger, then position the menu against that name. Inset properties (top, left, right, bottom) take the anchor() function; sizing (width, height) takes anchor-size().

CSS
/* Anchor the menu to its trigger. */
@supports (position-anchor: --a) {
  .trigger { anchor-name: --menu; }

  .menu {
    position: fixed;
    position-anchor: --menu;
    top: anchor(bottom);           /* place below the trigger */
    left: anchor(left);            /* align left edges */
    min-width: anchor-size(width); /* match the trigger width */
    margin-top: 0.375rem;
  }
}

Progressive enhancement only. Firefox and Safari do not ship anchor positioning yet (2026), so the rules live inside @supports (position-anchor: --a). Capable browsers get the anchored placement, and everyone else keeps a sensible fallback, here the menu stays centered and fully usable.

Platform honesty. <dialog> and showModal() are Baseline Widely available since March 2022, and inert since April 2023, both safe for production today. The Popover API is newer, Baseline Newly available since January 2025, so on an older browser base consider a fallback for popovers.

6

The pitfalls, and the WCAG nuance

Every one of these is a real dialog shipped broken. The native element prevents most of them; a hand-built modal has to guard against all of them.

TrapFocus escapes to the background. Tab lands on links behind the dialog. The native element inertizes the rest of the document for you; by hand you must set the inert attribute, not just pointer-events.

TrapFocus never returns to the trigger, most often because the open or close was animated, which drops the automatic return. Restore it in an event listener.

TrapNo accessible name. Missing aria-labelledby or aria-label fails WCAG 4.1.2 outright.

TrapClose only on outside click, with no keyboard exit. Always ship an explicit close button plus Escape.

TrapOpened with the open attribute, which produces a non-modal, no trap and no Escape and no inert. Use .showModal().

TrapNesting modals. Stacked dialogs confuse which Escape closes what. Prefer one dialog, or steps inside a single dialog.

The trap is legal, on one condition. A modal's focus trap looks like it violates WCAG 2.1.2 No Keyboard Trap, and it does not, as long as there is a way out. W3C: restricting focus to a subsection "does not fail the requirements of this criterion, as long as the user knows how to 'untrap' the focus and leave that component." Trap the focus on purpose, but Escape or a Cancel button must always release it. A modal with no keyboard exit does fail 2.1.2.

7

On a phone, the modal becomes a sheet

A centered modal is a desktop shape. On a phone it usually becomes a bottom sheet, a panel that rises from the bottom edge into the thumb's reach. What changes is only the presentation, position, anchor, and entry animation. The semantics do not move. A bottom sheet is still a modal dialog or alertdialog, and it owes every one of the six requirements from Section 1: an accessible name, focus in, a trapped Tab cycle, Escape to close, focus back to the trigger, and an inert background. The same native <dialog> can present as a sheet through CSS while keeping showModal() and its guarantees.

Do not trade a11y for the gesture. Drag-to-dismiss is a pointer nicety, not a substitute for the keyboard. Every sheet still needs Escape and a visible close button, and the drag must have a keyboard equivalent. The presentation is documented in Patterns / Mobile First; the doctrine on this page travels with it unchanged.

8

The rules

Each rule is stated in the positive, with the reason it exists. Where the wrong turn is easy to make, a recommended and an avoid card sit side by side.

RuleReach for the native <dialog> with .showModal() before building anything by hand.

WhyIt delivers all six accessibility requirements correctly for free: focus in, Tab trapped, Escape, inert background, focus return, top layer. A hand-built modal has to re-derive each one, which is where the bugs live.

Recommended

Open with .showModal(), close a form with method="dialog", style the dim with ::backdrop.

Avoid

Opening with the open attribute, which yields a non-modal with no trap, no Escape, and no inert.

RuleGive every modal an accessible name and a real close button, and never put tabindex on the <dialog>.

WhyAn unnamed dialog fails WCAG 4.1.2, and Escape alone is not discoverable. MDN calls a visible close button the most robust exit for all users, and warns the dialog itself is not interactive.

RuleUse role="alertdialog" for destructive or irreversible actions, with aria-describedby and initial focus on Cancel.

WhyThe role lets assistive tech treat the interruption specially, and focusing Cancel makes the dangerous choice deliberate rather than one stray Enter away.

Recommended

alertdialog with the message wired to aria-describedby and autofocus on Cancel.

Avoid

A plain dialog for a delete, or autofocus on the destructive button so one Enter wipes the data.

RuleBefore opening a modal, try a page, an inline message, a non-modal dialog, or a popover first.

WhyA modal steals context and demands an immediate answer. Most content does not earn that cost, and an interruption unrelated to the goal reads as annoying and erodes trust.

RuleKeep the focus trap, but always leave a keyboard exit, and if you animate, restore the focus return by hand.

WhyThe trap is legal under WCAG 2.1.2 only while the user can untrap with Escape or Cancel, and animating a dialog drops the automatic focus return that the six requirements depend on.

RuleReserve the --color-error family for the destructive path and keep everything else mono-indigo; radius 6px on buttons, 12px on the dialog.

WhyRed carries a single meaning, danger, so it stays out of chrome and buttons that do not destroy. The destructive fill uses red 600 because red 500 clears only 3.76:1 under white text.

Sources

The six requirements, the roles, and the keyboard model from the WAI-ARIA Authoring Practices Guide: Dialog (Modal) Pattern and Alert Dialog Pattern. The native element, showModal() vs show(), ::backdrop, top layer, method="dialog", the open and tabindex warnings, and Baseline support from MDN: <dialog>, inert, and the Popover API. The legal focus trap from WCAG 2.1 Understanding SC 2.1.2 No Keyboard Trap and the name and role floor from SC 4.1.2 Name, Role, Value. The cost of interrupting and when a modal is justified from Nielsen Norman Group, Modal & Nonmodal Dialogs. The animation-breaks-focus-return caveat and the focus-on-Cancel practice from web.dev, Building a dialog component. The one-thing-per-page stance from the GOV.UK Design System.