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.

What you write versus what the browser does Comparison of a custom div-based focus trap against the native dialog element opened with showModal, listing who handles each behaviour. What you write versus what the browser does✗ Custom div trap — all yoursTab and Shift+Tab cycling scriptaria-hidden or inert on every siblingEscape handler and cleanupz-index battles with sticky headersFocus return to the trigger✓ dialog + showModal() — nativeFocus contained by the browserBackground inert automaticallyEscape fires cancel and closesFocus returned on close
The native element replaces five pieces of fragile script with one method call.

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
What showModal() changes in the page Layers showing the top layer with the dialog and backdrop, the inert document beneath, and the focus-return record the browser keeps. What showModal() changes in the pageTop layerthe dialog and its ::backdrop, above every stacking contextInert documenteverything else: not focusable, not clickable, hidden from ATFocus recordthe previously focused element, restored on close
One call rearranges the page into these layers — and undoes it on close.

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.

Overlay types and the right primitive Matrix of overlay types common in data interfaces and the primitive to build each with, and whether the background should be inert. Overlay types and the right primitiveOverlayPrimitiveBackground inert?Edit or confirm dialogdialog + showModal()YesColumn filter popoverpopover or customNoRow action menumenu button patternNoBlocking side paneldialog + showModal()YesToast notificationrole="status"No — never focus it
Modal means inert background; everything else keeps the page usable.

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.

Permalink to "Related"

← Back to Keyboard Focus Trapping & Navigation for Data Interfaces