Native dialog Versus Custom Focus Traps
Permalink to "Native dialog Versus Custom Focus Traps"For most of the web’s history, a modal dialog meant a <div role="dialog">, a JavaScript focus trap that cycled Tab between the first and last focusable elements, aria-hidden on everything else, and an Escape handler. The HTML <dialog> element opened with showModal() now does all of that natively: it puts the dialog in the top layer, makes the rest of the document inert, contains focus, closes on Escape, and returns focus to the previously focused element on close.
This page compares the two, shows the native version for a typical data-app dialog (edit record, confirm delete), and lists the gaps you still fill yourself. It belongs to keyboard focus trapping & navigation.
Spec reference
Permalink to "Spec reference"HTMLDialogElement.showModal() (HTML Living Standard) opens the dialog as modal: it is placed in the top layer above all other content, a ::backdrop pseudo-element is rendered behind it, and every element outside the dialog becomes inert — unfocusable, unclickable and hidden from the accessibility tree. Escape fires a cancel event and closes the dialog. On close, the browser restores focus to the element that was focused before showModal() was called.
show() opens a non-modal dialog: no inertness, no focus containment, no Escape handling by default. The autofocus attribute on an element inside the dialog chooses initial focus; without it, the browser focuses the first focusable element (or the dialog itself in some engines).
ARIA: the element has an implicit role="dialog", and aria-modal="true" is implied for modal dialogs. It still needs an accessible name, usually aria-labelledby pointing at its heading.
Criteria: SC 2.1.2 No Keyboard Trap (the user must be able to leave — Escape and the close button), SC 2.4.3 Focus Order, SC 4.1.2 Name, Role, Value.
When to use native dialog — and when not to
Permalink to "When to use native dialog — and when not to"Use <dialog> with showModal() for every modal in a data interface: edit a record, confirm a destructive action, configure an export. Support is universal in current browsers, and it removes an entire category of bugs — focus escaping behind the dialog, screen readers reading the background, stacking-context problems with sticky table headers.
Keep a custom approach for non-modal overlays that should not make the page inert: a filter popover attached to a column header, a date picker, a column menu. Those need light containment (Tab cycling within, Escape closing) without blocking the rest of the page; see focus trapping in data grid filter popovers. The Popover API (popover attribute) now covers some of these natively as well.
The misapplication to name is opening a <dialog> with show() instead of showModal(). It looks the same with custom CSS, but nothing is inert and focus is not contained — users tab straight out into the table behind.
Annotated code example
Permalink to "Annotated code example"<!-- Trigger in a table row -->
<button type="button" data-edit="INV-1042">Edit INV-1042</button>
<!-- SC 4.1.2: named by its heading; implicit role=dialog, aria-modal when modal -->
<dialog id="edit-dialog" aria-labelledby="edit-title">
<form method="dialog"> <!-- buttons with value close the dialog -->
<h2 id="edit-title">Edit invoice INV-1042</h2>
<label>Amount <input name="amount" autofocus></label> <!-- initial focus -->
<label>Due date <input type="date" name="due"></label>
<div class="actions">
<button value="cancel" formnovalidate>Cancel</button>
<button value="save">Save</button>
</div>
</form>
</dialog>
const dialog = document.getElementById('edit-dialog');
let trigger = null;
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-edit]');
if (!btn) return;
trigger = btn;
dialog.showModal(); // SC 2.1.2 / 2.4.3: inert background, contained focus
});
dialog.addEventListener('close', async () => {
if (dialog.returnValue === 'save') {
await saveInvoice(new FormData(dialog.querySelector('form')));
status('Invoice INV-1042 saved.');
}
// The browser restores focus to the trigger — unless the trigger is gone
if (!trigger?.isConnected) substituteFocus()?.focus();
});
The close handler is where native dialogs still need you. If saving re-renders the table and the trigger button is replaced, the browser’s automatic focus return targets a detached element and focus falls to <body>. Checking isConnected and choosing a substitute — the same row’s new button, found by record id — closes that gap.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected behaviour | Announcement |
|---|---|---|
| Activate trigger | Dialog opens; focus on autofocus field |
“Edit invoice INV-1042, dialog. Amount, edit text” |
Tab from last control |
Wraps to first control in the dialog | Next control |
Escape |
cancel then close; focus returns to trigger |
“Edit INV-1042, button” |
| Screen reader virtual cursor | Cannot reach page content behind | — (background inert) |
Browser chrome (F6, address bar) |
Reachable — native modals do not trap the browser UI | By design; satisfies SC 2.1.2 |
Integration context
Permalink to "Integration context"Inertness is also available on its own, through the inert attribute, for cases that are not dialogs — a side panel that should block the page, a loading overlay. The details are in using inert to isolate background content.
For dialogs whose action deletes or rebuilds the triggering row, pair the native dialog with the substitute-target logic in restoring focus after closing complex modals and preserving focus when rows are deleted.
Gotchas
Permalink to "Gotchas"Escape inside grid editors. A grid inside a dialog uses Escape to cancel cell edits. Stop propagation in the editor, or the first Escape closes the whole dialog — see select and date editors inside grid cells.
Dialogs that should not close on Escape. A dialog with unsaved changes can intercept cancel with preventDefault() and ask for confirmation — but it must still offer a keyboard way to leave.
Scrolling long dialogs. A dialog taller than the viewport needs its own scroll container; at 400% zoom, check that every control is reachable.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Does the native dialog element trap focus?
When opened with showModal(), yes. The browser makes everything outside the dialog inert, so Tab cycles within the dialog, and it still allows the user to reach the browser’s own interface, which is what SC 2.1.2 requires.
Do I still need aria-modal with the dialog element?
No. A dialog opened with showModal() is exposed as modal automatically. It does still need an accessible name, usually through aria-labelledby pointing at its heading.
Does the dialog element return focus when it closes?
Yes, to the element that was focused before it opened. If that element has been removed or replaced — common when a dialog edits or deletes a table row — handle the close event and focus a substitute yourself.
When should I still write a custom focus trap?
For non-modal overlays that should keep the rest of the page usable, such as column filter popovers and date pickers, and in environments that must support very old browsers. Modal dialogs no longer need one.
Related
Permalink to "Related"- Using inert — the mechanism showModal applies for you
- Focus trapping in filter popovers — where a custom trap still fits
- Restoring focus after modals — the return path
← Back to Keyboard Focus Trapping & Navigation for Data Interfaces