Modals & Dialogs
Native dialog element vs. custom ARIA modals — focus trap, aria-modal, and focus return
Overview
Modal dialogs require a name, deliberate initial focus, modal background behavior, Escape handling, and contextual focus restoration. Native <dialog> with .showModal() provides the browser’s modal behavior, top-layer placement, background inertness, and a ::backdrop; authors still choose the initial focus target and where focus returns.
WCAG Criteria:
- 2.1.2 No Keyboard Trap requires users to be able to leave any component they can enter, or be told a non-standard exit method. Modal focus containment is an APG/native-dialog convention, not the criterion’s text.
- 2.4.3 Focus Order requires an order that preserves meaning and operability.
- 4.1.2 Name, Role, Value requires the dialog and controls to expose valid programmatic semantics.
Key requirements:
- Use the native
<dialog>element with.showModal()(preferred over custom ARIA) - Label the dialog with
aria-labelledbypointing to its heading - Choose initial focus based on content and risk; use
autofocusor explicit focus where necessary - Return focus to the trigger when it still exists, otherwise to the next logical workflow target
- Close on Escape key (native
<dialog>does this automatically)
Modal Dialog
Custom ARIA Modal vs. Native Dialog
Inaccessible
View inaccessible code
<!-- Custom div modal — no focus trap, no Escape, no inert background -->
<button onclick="document.getElementById('overlay').style.display='flex'">
Delete Account
</button>
<div id="overlay" style="display:none; position:fixed; inset:0;">
<div>
<h2>Are you sure?</h2>
<p>This action cannot be undone.</p>
<button onclick="close()">Cancel</button>
<button onclick="close()">Delete</button>
</div>
</div>Accessible
View accessible code
<!-- Native <dialog> — built-in focus trap, Escape, ::backdrop -->
<button id="trigger" onclick="document.getElementById('dlg').showModal()">
Delete Account
</button>
<dialog
id="dlg"
aria-labelledby="dlg-title"
aria-describedby="dlg-desc"
onclose="document.getElementById('trigger').focus()"
>
<h2 id="dlg-title">Are you sure?</h2>
<p id="dlg-desc">This action cannot be undone.</p>
<button autofocus onclick="this.closest('dialog').close()">Cancel</button>
<button onclick="this.closest('dialog').close()">Delete</button>
</dialog>What’s wrong with the custom div modal?
- No
dialogrole — screen readers don’t announce it as a dialog - No focus trap — Tab key moves to elements behind the overlay
- No Escape key handling — keyboard users can’t dismiss it
- Background content remains interactive — users can click/tab behind the modal
- Focus is not managed on open or close
What the native <dialog> gives you for free:
- Automatic focus trap (Tab/Shift+Tab wraps within the dialog)
- Escape key closes the dialog (fires the
closeevent) - Background content is automatically
inert(unfocusable, unclickable) ::backdropCSS pseudo-element for the overlay- Built-in
dialogrole in the accessibility tree
Expected semantics:
| Version | Programmatic result |
|---|---|
| Custom div | Heading text exists, but there is no dialog role, modal state, or managed focus. |
Native <dialog> | A named modal dialog exposes its short description and focus begins on Cancel. Exact speech varies. |
You still need to add:
aria-labelledbypointing to the dialog headingaria-describedbywhen the description is short, plain text; omit it for long or structured content so users can navigate the structure- Focus restoration on close (via the
oncloseevent) - Focus the least destructive action for confirmation dialogs