Lesson 14 / 25
Modal Dialogs
The specification of the modal dialog; the distinction between the native dialog element and a manual ARIA setup, how focus containment differs from a keyboard trap, testing the return point with a state machine, and measurable constraints.
Contents
Every component up to this point has stayed inside the page’s flow. Buttons, inputs, cards, tabs, tables: each holds a place in document order, each is reached by the tab key in its turn, and none blocks the reading of any other. A user can move through the whole page without stopping at any of them.
The components in this topic rise above that flow. They open a temporary layer on screen, sometimes take over focus, sometimes appear without the user asking for them at all. Standing above the flow raises two new questions: what happens to the page underneath while this layer is open, and where does the user return to when the layer closes? The first lesson takes on the sharpest form of both questions.
What It Solves, When Not to Use It
A modal dialog is a layer that forces focus onto a single decision by disabling the content beneath it. It solves exactly one problem: obtaining confirmation for an action that cannot be undone, or collecting information that is required before the flow can continue. In the catalog interface, borrow confirmation fits this definition — the record moves to the user’s account, a return date is set, and access for other patrons closes.
The situations where a modal dialog should not be used are more numerous, and each belongs to a separate pattern:
- Informing. Opening a window to announce a result imposes an unnecessary dismiss action on the user. The notification pattern does this work without taking focus.
- Long content. Content that does not fit inside a window belongs on its own page; scrolling inside a window conflicts with scrolling outside it.
- Multi-step work. Steps need an address; a flow opened inside a modal dialog cannot be managed with the back button and cannot be shared.
- Help text. A field’s description is written next to the field; putting it in a window pulls the user away from filling in the field.
The distinction also has a non-modal counterpart. A non-modal dialog stays on screen but does not disable what is behind it; the user can move back and forth between the window and the page. A filter panel can be non-modal; borrow confirmation cannot.
Native Element First, ARIA Second
Markup has an element set aside for a dialog window: dialog. Opened as modal, it hands
three jobs to the user agent — disabling the content behind it, placement in the top
layer, and drawing the backdrop. A window built with this element carries the
role="dialog" role and the modal state implicitly; no ARIA needs to be written on top of
it.
Where the native element cannot be used, the setup is built by hand and requires four bindings:
| Part | Source |
|---|---|
| Role | role="dialog", role="alertdialog" for warnings that defer a decision |
| Modal state | aria-modal="true" |
| Name | bound to the window’s heading with aria-labelledby |
| Description | bound to the summary sentence in the body with aria-describedby |
The source of the name is a design decision. A heading of “Confirm” is not a name; the window’s name must state what it does: “Confirm borrowing”. The rule from the Screen Reader Experience lesson applies here too — the role name is not written into the name, so the heading does not become “Confirm borrowing dialog window”.
The aria-modal attribute alone is not enough. This attribute tells the accessibility
tree that “what is behind is closed”, but it does not determine where the tab key goes and
it does not stop a mouse click on a button behind the window. The content behind is also
made inert: it is made unable to receive focus and is removed from the accessibility
tree. The point where a two-source setup collapses into one source is opening the native
element as modal.
Keyboard Contract
| Key | Behavior |
|---|---|
| Tab | Moves focus to the next element inside the window; wraps from the last to the first |
| Shift+Tab | Moves to the previous element; wraps from the first to the last |
| Escape | Closes the window and sends focus to the return point |
| Enter | Runs the primary action; while a text field has focus, the field’s own rule applies |
Where focus lands on opening depends on the content. If the window has a single primary action, focus is placed on the window itself or its heading, so the user can read the content from the start. Placing focus directly on the confirm button skips past the text that is being confirmed. If the window contains a form, focus goes to the first field. Placing focus on a destructive action — “Delete”, “Cancel” — produces an accident when a user trying to close the window with Escape instead presses Enter.
Where focus goes on closing is the most critical field in the component’s specification. The return point is the element that opened the window, and it is recorded when the window opens. On closing, focus returns there; if it does not, focus falls to the start of the document and the user loses their place in the list.
Testing Containment and the Return Point
This behavior is testable, because it is entirely a sequential state machine: the page’s focus order, the window’s focus order, a stack, and a return point. The test suite below verifies the pattern’s contract item by item.
// modal.test.mjs — the modal dialog's focus containment and return point state machine import { test } from "node:test"; import assert from "node:assert/strict"; // The page's focusable elements, in document order. const PAGE = ["search", "filter", "row-1-borrow", "row-2-borrow", "footer-help"]; // Modal dialog definitions: id -> focusable elements inside it const WINDOWS = { "borrow-confirm": ["confirm-heading", "confirm-duration", "confirm-cancel", "confirm-borrow"], "late-warning": ["warning-ok"], }; function machine(page = PAGE) { return { focus: null, stack: [], // { window, returnTo } — nested open windows remaining: [...page], // page elements that can be removed log: [], // return log }; } const topWindow = (d) => (d.stack.length ? d.stack[d.stack.length - 1] : null); // Scope of focus cycling: only that window's elements while a window is open. function scope(d) { const top = topWindow(d); return top ? WINDOWS[top.window] : d.remaining; } function focusOn(d, id) { assert.ok(scope(d).includes(id), `${id} outside scope`); d.focus = id; } function tab(d, back = false) { const s = scope(d); const i = s.indexOf(d.focus); // If arriving from a focus outside the scope, start at the beginning. const next = i < 0 ? 0 : (i + (back ? -1 : 1) + s.length) % s.length; d.focus = s[next]; return d.focus; } function openWindow(d, window) { d.stack.push({ window, returnTo: d.focus }); d.focus = WINDOWS[window][0]; d.log.push(`opened: ${window}, return point: ${d.stack[d.stack.length - 1].returnTo}`); } // If the return point is gone: the nearest preceding element still on the page, else the first element. function returnTarget(d, wanted) { if (wanted && d.remaining.includes(wanted)) return wanted; const original = PAGE.indexOf(wanted); for (let i = original - 1; i >= 0; i--) if (d.remaining.includes(PAGE[i])) return PAGE[i]; return d.remaining[0] ?? null; } function closeWindow(d) { const top = d.stack.pop(); assert.ok(top, "no open window"); if (d.stack.length) { d.focus = top.returnTo; } // inner window to outer window else d.focus = returnTarget(d, top.returnTo); d.log.push(`closed: ${top.window}, focus: ${d.focus}`); return d.focus; } const escape = (d) => (d.stack.length ? closeWindow(d) : d.focus); test("opening the modal dialog moves focus inside and records the return point", () => { const d = machine(); focusOn(d, "row-2-borrow"); openWindow(d, "borrow-confirm"); assert.equal(d.focus, "confirm-heading"); assert.equal(topWindow(d).returnTo, "row-2-borrow"); }); test("tab wraps from the window's last element to its first", () => { const d = machine(); focusOn(d, "row-2-borrow"); openWindow(d, "borrow-confirm"); const path = [d.focus]; for (let i = 0; i < 4; i++) path.push(tab(d)); assert.deepEqual(path, ["confirm-heading", "confirm-duration", "confirm-cancel", "confirm-borrow", "confirm-heading"]); }); test("shift+tab wraps from the first element to the last", () => { const d = machine(); focusOn(d, "row-2-borrow"); openWindow(d, "borrow-confirm"); assert.equal(tab(d, true), "confirm-borrow"); }); test("page elements do not enter the tab cycle while the window is open", () => { const d = machine(); focusOn(d, "search"); openWindow(d, "borrow-confirm"); const seen = new Set(); for (let i = 0; i < 12; i++) seen.add(tab(d)); assert.deepEqual([...seen].sort(), ["confirm-borrow", "confirm-cancel", "confirm-duration", "confirm-heading"]); assert.equal([...seen].some((x) => PAGE.includes(x)), false); }); test("escape closes the window and returns focus to the triggering element", () => { const d = machine(); focusOn(d, "row-2-borrow"); openWindow(d, "borrow-confirm"); tab(d); tab(d); assert.equal(escape(d), "row-2-borrow"); assert.equal(d.stack.length, 0); }); test("nested windows resolve in stack order", () => { const d = machine(); focusOn(d, "row-1-borrow"); openWindow(d, "borrow-confirm"); focusOn(d, "confirm-borrow"); openWindow(d, "late-warning"); assert.equal(d.focus, "warning-ok"); assert.equal(escape(d), "confirm-borrow"); // returns to outer window, not the page assert.equal(escape(d), "row-1-borrow"); }); test("if the return point was removed, focus does not vanish and falls to the previous element", () => { const d = machine(); focusOn(d, "row-2-borrow"); openWindow(d, "borrow-confirm"); d.remaining = d.remaining.filter((x) => x !== "row-2-borrow"); // row left the list assert.equal(closeWindow(d), "row-1-borrow"); assert.notEqual(d.focus, null); }); test("the return log preserves the order of opening and closing", () => { const d = machine(); focusOn(d, "filter"); openWindow(d, "borrow-confirm"); openWindow(d, "late-warning"); closeWindow(d); closeWindow(d); assert.deepEqual(d.log, [ "opened: borrow-confirm, return point: filter", "opened: late-warning, return point: confirm-heading", "closed: late-warning, focus: confirm-heading", "closed: borrow-confirm, focus: filter", ]); });
✔ opening the modal dialog moves focus inside and records the return point (0.368291ms) ✔ tab wraps from the window's last element to its first (0.290208ms) ✔ shift+tab wraps from the first element to the last (0.046375ms) ✔ page elements do not enter the tab cycle while the window is open (0.07125ms) ✔ escape closes the window and returns focus to the triggering element (0.072875ms) ✔ nested windows resolve in stack order (0.044875ms) ✔ if the return point was removed, focus does not vanish and falls to the previous element (0.065125ms) ✔ the return log preserves the order of opening and closing (0.047292ms) ℹ tests 8 ℹ suites 0 ℹ pass 8 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 35.956291
The duration fields vary on every run; what matters is the counters.
The eighth test catches the most often skipped aspect of this pattern. Once borrowing is confirmed, the row can drop out of the “available to borrow” list — meaning the return point no longer exists. If focus is not placed anywhere in this case, it falls to the start of the document and the keyboard user loses their place in the list. The specification also has to state where focus falls when the return point is gone: the nearest preceding element, or the start of the list if none remains.
The sixth test verifies the stack behavior of nested windows. Opening a second window from inside a modal dialog is a setup that should be avoided, but when it happens, Escape must close only the topmost window and leave focus on the one beneath. Code that closes both windows at once also closes the user’s unfinished work.
Measurable Constraints
The pattern’s conformance is tested against four criteria, and each has a concrete threshold.
2.1.2 No Keyboard Trap. The difference between focus containment and a keyboard trap was defined in the Keyboard Access lesson and rests on a single condition: if containment can be exited with a standard key, it is containment; if it cannot, it is a trap. A modal dialog that does not close with Escape fails this criterion even if tab cycling works correctly.
2.4.3 Focus Order. Focus entering the window when it opens and returning to the return point when it closes is what this criterion requires. The first and fifth items in the test suite correspond directly to this criterion.
2.4.11 Focus Not Obscured (Minimum). A focused element cannot be entirely covered by a layer the author added. In a modal dialog this criterion can be violated in two directions: the window itself can cover a focused element on the page, or a sticky footer bar inside the window can cover the action button beneath it. The criterion rests on a “not visible at all” threshold; partial obscuring is addressed separately at the AAA level.
2.5.8 Target Size (Minimum). The close button is the modal dialog’s smallest target and must cover an area of at least 24 x 24 pixels. Even if the icon itself is drawn at 16 pixels, the hit area is expanded to this threshold; the target size decision from the Screen Size and Input Type lesson becomes a conformance requirement here.
A fifth constraint is not tied to a criterion number but is still measurable: there must be more than one way to close the window. Escape, a close button, and — in non-destructive windows — clicking the backdrop. Closing by clicking the backdrop produces accidental closes in windows where data is entered, so it is disabled in windows that contain a form.
Common Mistakes and How to Recognize Them
Background scrolling continues. If wheel movement scrolls the list behind the window while it is open, disabling is incomplete. How to recognize it: trying to scroll the page while the window is open is enough.
Containment is wired only to the tab key. Tab cycling is kept inside the window, but focus enters the page behind it when the user leaves for the browser’s address bar and returns. How to recognize it: seeing in the code that containment is bound only to a key event and not a focus event, or counting the unhidden links in the document while the window is open.
The return point was never recorded. If focus falls to the start of the page when the user closes the borrowing window, the return point was never held. How to recognize it: pressing the tab key once after opening and closing the window — if focus continues from the page’s first element instead of the middle of the list, the defect is there.
The heading is not bound to the name. If the window has a visual heading but no
aria-labelledby binding, it opens without a name. How to recognize it: the window’s name
field is empty in the accessibility tree; this is a defect automated checkers catch with
an existence rule.
Summary
- A modal dialog is used only when an irreversible confirmation or information required for the flow to continue is needed; informing, long content, multi-step work, and help text belong to other patterns.
- The native dialog element, opened as modal, hands the role, the modal state, disabling, and the backdrop to the user agent; in a manual setup, the role, modal state, name, and description are bound separately.
- Focus containment is containment as long as it can be exited with a standard key; when it cannot, it turns into a keyboard trap and violates the 2.1.2 criterion.
- The return point is recorded when the window opens; where focus falls when the return point is gone is also part of the specification.
- In nested windows, Escape closes only the topmost window and leaves focus on the one beneath.
- The pattern’s conformance is tested against the 2.1.2, 2.4.3, 2.4.11, and 2.5.8 criteria; the close button is expanded to a hit area of at least 24 x 24 pixels.
Next Step
The modal dialog took over focus deliberately, and the user opened it deliberately. The next layer is the opposite: it appears on its own when the user hovers over or focuses an element, does not take focus, and disappears quickly. This spontaneity makes it the most often misbuilt component — a setup that works with the mouse never reaches keyboard or touch input at all, and the text inside a layer that disappears goes unread. The next lesson ties the conditions under which a tooltip should appear, how long it should stay, and how it can be dismissed to a criterion.
To keep your progress and take notes, Log in
My notes
Log in to take notes.