Articles
Accessible Modal Coding Interview: Focus Traps, Escape, and Nested Dialogs
Practice an accessible modal coding interview with native dialog, focus wrapping, Escape, nested dialogs, focus restoration, and keyboard acceptance tests.
An accessible modal interview is a focus-management problem before it is a styling problem. Opening the dialog should move focus inside, make the background unavailable for interaction, and provide a keyboard route to close it. Closing should return the user to a meaningful place. With nested dialogs, only the topmost scope should respond to dismissal, and closing the child should reactivate its parent.
This guide distinguishes W3C guidance and browser behavior from an original coding exercise. The exercise is not a reported employer assessment.
What contract should you clarify before writing a focus trap?
Ask whether the task permits native HTML dialog, which browsers matter, and whether “nested” means one modal launched from another. Also clarify dismissal: Escape, a visible close control, successful form submission, and clicking outside are separate behaviors. Do not assume that clicking a backdrop must discard an unfinished form.
For this original exercise, use two dialogs: Settings and Confirm reset. Opening Settings starts on its name field. Opening Confirm reset starts on Cancel, because reset is destructive. Escape closes only the top dialog. After canceling the confirmation, focus returns to the Reset button in Settings. After closing Settings, it returns to the page's Open settings button.
Write those transitions down before selecting libraries. They are acceptance criteria that remain useful if the implementation changes. A modal that looks right but leaves keyboard focus on a background link fails the contract even if mouse interaction appears fine.
W3C's modal pattern describes contained tab navigation, Escape dismissal, an accessible name, and focus restoration with exceptions when the original invoker no longer exists. It also emphasizes that initial focus depends on content and task, rather than one universal first-button rule. W3C modal dialog pattern
Why start with native dialog when it is available?
A native dialog opened with showModal() enters the top layer and makes the rest of the document inert. Merely setting its open attribute does not provide the same modal behavior. Native support handles important platform mechanics, while the application still owns labels, task flow, and meaningful dismissal. HTML dialog documentation
That is a useful interview boundary: explain which behavior belongs to the browser and which behavior your code supplies. Do not present twenty lines of custom event handling as a complete substitute for all native interaction and accessibility behavior.
The following markup is an original starting point. It includes names for both dialogs and explicit cancel controls. The confirmation deliberately focuses the safer choice.
<button id="openSettings">Open settings</button>
<dialog id="settings" aria-labelledby="settingsTitle">
<h2 id="settingsTitle">Settings</h2>
<label>Name <input id="name" autofocus></label>
<button id="askReset" type="button">Reset settings</button>
<form method="dialog">
<button id="closeSettings" value="close">Close settings</button>
</form>
</dialog>
<dialog id="confirmReset" aria-labelledby="confirmTitle">
<h2 id="confirmTitle">Reset settings?</h2>
<p>This removes your unsaved settings.</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="reset">Reset</button>
</form>
</dialog>
The two dialogs can be siblings in the DOM. “Nested” here describes the user's task and the active modal stack, not a requirement that one HTML element physically contain the other. Keep each name unique; duplicate heading IDs make accessible-name relationships ambiguous.
Add the smallest behavior needed to exercise the flow:
const settings = document.getElementById('settings');
const confirmation = document.getElementById('confirmReset');
const openSettings = document.getElementById('openSettings');
const askReset = document.getElementById('askReset');
const nameField = document.getElementById('name');
openSettings.addEventListener('click', () => {
settings.returnValue = '';
settings.showModal();
});
askReset.addEventListener('click', () => {
confirmation.returnValue = '';
confirmation.showModal();
});
confirmation.addEventListener('close', () => {
if (confirmation.returnValue === 'reset') {
nameField.value = '';
}
});
Resetting returnValue avoids treating a later Escape dismissal as if it repeated a previous Reset submission. That small state detail matters more than adding elaborate animations to the first version. This sample relies on native focus behavior; the next tests determine whether additional application-specific restoration is needed.
For a strict wrap-between-controls requirement in this fixed exercise, add the following handler. In our browser check, the native dialog prevented background interaction, but Tab from its final control could move to browser-level focus. Native modality and an application-specific wrap rule are not identical. This handler deliberately covers only the visible input and buttons in the sample; it is not a general tabbable-element library.
for (const dialog of [settings, confirmation]) {
dialog.addEventListener('keydown', event => {
if (event.key !== 'Tab') return;
const controls = [...dialog.querySelectorAll(
'button:not([disabled]), input:not([disabled])'
)].filter(node => node.getClientRects().length > 0);
const first = controls[0];
const last = controls[controls.length - 1];
if (!first) return; // This sample always retains a close control.
const current = document.activeElement;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
});
}
How do you choose initial focus without hiding the content?
For a short data-entry dialog, focusing the first useful field can let the user begin immediately. For a long explanation, that may scroll the introduction out of view or cause important structure to be skipped. W3C recommends considering a static heading or introductory element with tabindex="-1" for complex content. Initial-focus guidance
In the Settings exercise, the Name input is a reasonable initial destination because editing it is the primary action. In Confirm reset, choose Cancel so that an accidental confirmation gesture does not start on the destructive action. These are task-specific choices, not a rule that all dialogs should focus Cancel.
Avoid adding aria-describedby to an entire long dialog merely to make a screen reader announce everything at once. The user may need to navigate headings, lists, or form controls separately. A concise confirmation sentence is a different case from a multipage settings form.
Also distinguish focusability from tab order. A heading with tabindex="-1" can receive programmatic focus without becoming a regular Tab stop. A positive tabindex can create a surprising order across controls and complicate containment. Prefer the document's natural order unless the task gives a strong reason otherwise.
What changes when a second dialog opens?
Treat the confirmation as the active scope. The parent remains open but should not accept interaction while the child is modal. After the child closes, the parent becomes the active scope again; the page is still blocked until the parent closes.
Here is the original transition trace to use as an acceptance asset:
| Action | Open dialogs | Focus destination | Background still unavailable |
|---|---|---|---|
| Open Settings | Settings | Name input | Main page |
| Open Confirm reset | Settings, confirmation | Cancel | Main page and Settings |
| Escape in confirmation | Settings | Reset settings button | Main page |
| Close Settings | None | Open settings button | None |
One application-level Escape handler attached to document can accidentally close both dialogs. A second handler may run after the first closes the child and observe the parent as active. If implementing a custom stack, select one top entry for the event and handle dismissal once; do not repeatedly pop until there are no dialogs left.
For native dialogs, prefer the platform's cancellation behavior unless the product requires a deliberate override. Intercepting a cancel event means taking responsibility for the resulting state. Preventing dismissal without offering a clear alternative can trap a user in an unfinished operation. HTML dialog behavior
What if the opener disappears before close?
A stored element reference is not a guarantee of a valid destination. A successful action could remove a row and its Delete button, rerender a toolbar, or disable the control that launched the dialog. Decide on a logical fallback as part of the task flow.
In the original exercise, if Reset settings disappears, focus could move to Close settings or to the Settings heading. It should not jump behind the still-open parent to the page's Open settings button. If the whole parent closes as part of the action, the correct scope changes again.
A custom restoration routine should check that the target is still connected, usable, and inside the scope that is allowed to receive focus. isConnected alone is insufficient: a connected element can be hidden, disabled, or inert. After attempting focus, verify the resulting active element rather than assuming the call succeeded.
For a list item deletion, ask where the reader continues their work. The next row, previous row, or list heading may be more helpful than a generic page button. That is an interaction decision you can explain and test; a focus trap cannot infer it automatically.
Why is a selector-based custom trap incomplete?
A common interview implementation gathers button, input, a[href], intercepts Tab, and wraps between the first and last matches. It demonstrates the basic idea but misses disabled controls, hidden content, dynamically inserted fields, radio-group behavior, shadow boundaries, and controls with custom tabindex values.
If the interviewer requires a custom dialog, state the supported scope before coding. For a constrained example with ordinary visible native controls, recompute the available destinations when necessary and provide a focusable fallback when the list is empty. Avoid leaving the user at document.body because a form is still loading.
Do not use aria-modal="true" as a substitute for actual modality. ARIA communicates semantics; it does not implement keyboard behavior or prevent pointer interaction with the background. Conversely, blocking clicks without an accessible name and predictable focus is not a complete accessible modal.
Use inert deliberately when implementing the background restriction yourself, and restore prior state when closing. Multiple overlays need coordinated ownership: one dialog closing must not remove a restriction still required by another. MDN inert reference
For production, a maintained accessible dialog primitive can be preferable to a bespoke selector algorithm. In the interview, explain the hard parts you would delegate and demonstrate the smaller contract you implemented. Avoid claiming exhaustive accessibility support from a happy-path demo.
How should you test the modal during the interview?
Start with keyboard-only operation. Open Settings, confirm the active element, move forward and backward across its controls, then open the confirmation. Verify Escape closes only the confirmation and restores the correct parent control. Close the parent and check the original page destination.
Repeat after dynamically disabling the first control, removing the opener, and inserting validation feedback. Test a narrow viewport and a dialog taller than the screen. Focus should remain visible rather than moving to content hidden above or below a fixed panel.
Add a screen-reader pass when the environment permits it: verify the name, role, relevant description, and reading order. An automated check can catch missing labels and some semantic defects, but it does not establish that the complete task is usable with assistive technology.
Finally, test repeated open-close cycles. Stale listeners and retained return values may only appear on the second attempt. For the sample, perform Reset once, enter a new name, reopen the confirmation, and press Escape. The new name must remain. That assertion checks real task state, not merely whether a panel vanished.