
Build Accessible Modals with the HTML Dialog Element
Modal dialogs look simple, but a hand-built overlay often fails in ways that are invisible to mouse users: focus escapes into the page, the Escape key does nothing, background controls remain available to a screen reader, or focus disappears when the modal closes.
The native HTML <dialog> element removes much of that plumbing. When opened with showModal(), it enters the browser's top layer, receives a ::backdrop, and makes the rest of its document inert. Browser support for showModal() has been broadly available since March 2022, but correct markup, focus placement, and action handling are still your responsibility.
This tutorial builds a reusable confirmation dialog with progressive enhancement, safe focus behavior, responsive CSS, and tests you can run before shipping.
Choose a Modal Only When Work Must Pause
A modal interrupts the current workflow. Use it when the user must make a decision before continuing, such as confirming an irreversible deletion or completing a short, focused form.
Do not use a modal for passive status, long documentation, ordinary navigation, or information that can sit inline. A non-modal popover, disclosure, toast, or dedicated page is usually less disruptive.
The difference is behavioral, not just visual:
dialog.showModal()blocks interaction outside the dialog and places it in the top layer.dialog.show()opens a non-modal dialog and leaves the page interactive.- Adding the
openattribute directly also creates a non-modal state and skips important open/close behavior; the HTML Standard recommends the methods.
Write Semantic Dialog Markup
Build a delete confirmation with a visible title, concise description, and explicit actions:
<button type="button" id="open-delete-dialog">
Delete project
</button>
<dialog
id="delete-dialog"
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<form method="dialog" class="dialog-panel">
<h2 id="delete-dialog-title">Delete this project?</h2>
<p id="delete-dialog-description">
This permanently removes the project and its deployments.
</p>
<div class="dialog-actions">
<button type="submit" value="cancel" autofocus>
Keep project
</button>
<button type="submit" value="confirm" class="danger">
Delete project
</button>
</div>
</form>
</dialog>
<p id="delete-status" role="status"></p>
The native element already exposes dialog semantics. aria-labelledby connects its accessible name to the visible heading; aria-describedby is appropriate because this description is short and simple. For a dialog with several paragraphs, a list, or a table, WAI-ARIA guidance recommends letting users navigate that structure instead of forcing it into one long accessible description.
The least destructive action has autofocus. This matches WAI guidance for irreversible operations: keyboard focus should not make the dangerous action the easiest accidental choice. Every modal also needs a visible button that can close it.
method="dialog" is special. Activating a submit button closes the dialog, stores that button's value in dialog.returnValue, and does not send the form to a server.
Open the Dialog and Handle the Result
Keep server-side deletion separate from the dialog form. The dialog gathers intent; your application performs the operation only after a confirmed result.
const openButton = document.querySelector('#open-delete-dialog');
const dialog = document.querySelector('#delete-dialog');
const status = document.querySelector('#delete-status');
openButton.addEventListener('click', () => {
if (typeof dialog.showModal !== 'function') {
window.location.assign('/projects/current/delete');
return;
}
dialog.returnValue = '';
dialog.showModal();
});
dialog.addEventListener('close', async () => {
if (dialog.returnValue !== 'confirm') {
status.textContent = 'Deletion canceled.';
openButton.focus();
return;
}
try {
openButton.disabled = true;
status.textContent = 'Deleting project…';
const response = await fetch('/api/projects/current', {
method: 'DELETE',
headers: { 'Accept': 'application/json' },
});
if (!response.ok) throw new Error(`Delete failed: ${response.status}`);
window.location.assign('/projects?deleted=1');
} catch (error) {
openButton.disabled = false;
status.textContent = 'Could not delete the project. Try again.';
openButton.focus();
}
});
The feature-detection branch uses a normal confirmation page as a durable fallback. That is more reliable than shipping a partial custom modal for an old or embedded browser. If your audience requires a polyfill, choose a maintained one and test its focus behavior with assistive technology.
Reset returnValue before every opening. Pressing Escape closes a modal through the browser's close-request behavior, but it does not represent confirmation. Only the explicit confirm value should trigger deletion.
Browsers keep track of the previously focused element, and the HTML Standard includes focus restoration behavior. Explicitly returning focus in the application path remains useful when an asynchronous action changes the DOM or the invoking control is replaced. If deletion succeeds and navigation does not occur, move focus to the next logical workflow target rather than to a removed button.
Preserve Escape and Cancel Behavior
The cancel event fires when the user requests that a modal close, commonly by pressing Escape. It is cancelable, but blocking it without a strong reason traps users.
Use it for cleanup or state reporting while allowing the browser to close:
dialog.addEventListener('cancel', () => {
dialog.returnValue = 'cancel';
// Do not call preventDefault(); Escape should keep working.
});
Do not add a global keydown listener that manually cycles Tab through guessed selectors. Native modal dialogs already keep the rest of the document inert, and custom focus traps are a common source of bugs with disabled controls, shadow DOM, and dynamic content.
Also avoid making backdrop clicks confirm an action. A click outside the dialog is imprecise, especially on touch devices. If product requirements call for light dismiss, treat it as cancellation, test pointer-down and pointer-up behavior carefully, and keep an explicit close button.
Style the Panel and Backdrop
The dialog is a normal box that you can style with CSS. The backdrop exists only for a modal dialog opened with showModal().
#delete-dialog {
width: min(32rem, calc(100vw - 2rem));
max-height: min(36rem, calc(100dvh - 2rem));
padding: 0;
border: 0;
border-radius: 1rem;
color: #18202b;
background: #ffffff;
box-shadow: 0 1.5rem 4rem rgb(16 24 40 / 28%);
overflow: auto;
}
#delete-dialog::backdrop {
background: rgb(15 23 42 / 62%);
backdrop-filter: blur(2px);
}
.dialog-panel {
padding: clamp(1.25rem, 4vw, 2rem);
}
.dialog-panel h2 {
margin-block: 0 0.75rem;
font-size: 1.5rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.dialog-actions button {
min-height: 2.75rem;
padding-inline: 1rem;
}
.dialog-actions button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
}
.dialog-actions .danger {
color: #ffffff;
background: #b42318;
border-color: #b42318;
}
@media (max-width: 32rem) {
.dialog-actions {
flex-direction: column-reverse;
}
.dialog-actions button {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
#delete-dialog,
#delete-dialog::backdrop {
animation: none;
}
}
Use 100dvh so the maximum height follows the dynamic mobile viewport, and allow the panel to scroll. Keep the beginning of long content visible when focus moves. If the first interactive control would sit below the fold, WAI guidance suggests focusing a short static element near the top with tabindex="-1".
Do not remove the focus outline. A custom :focus-visible style can match the design system, but it must remain clearly visible against every button state.
Handle Long or Dynamic Content
For a long dialog, give the title programmatic focus and move focus there after opening:
<h2 id="terms-title" tabindex="-1">Review updated terms</h2>
termsDialog.showModal();
document.querySelector('#terms-title').focus();
This keeps the beginning visible and lets screen-reader users navigate headings, lists, and paragraphs naturally. In that case, omit aria-describedby if it would flatten a complex body into one oversized announcement.
When content updates after opening, avoid replacing the focused element. For validation errors, keep the dialog open, render a concise error summary, and move focus to the summary or the first invalid field. Do not close and reopen the modal just to display an error.
Test Behavior, Not Just Appearance
A visual snapshot cannot prove that a modal is accessible. Run this manual keyboard pass:
- Put focus on the open button and press Enter.
- Confirm focus moves inside the dialog to the intended element.
- Press Tab and Shift+Tab through every control; focus must remain inside.
- Press Escape; the dialog should close without performing the action.
- Confirm focus returns to the opener or the next logical element.
- Open again and confirm with the button; the action should happen exactly once.
- Zoom to 200% and test a narrow mobile viewport without clipped content.
Add browser automation for the critical contract:
import { test, expect } from '@playwright/test';
test('delete dialog supports keyboard cancellation', async ({ page }) => {
await page.goto('/projects/current');
const opener = page.getByRole('button', { name: 'Delete project' });
await opener.focus();
await page.keyboard.press('Enter');
const modal = page.getByRole('dialog', { name: 'Delete this project?' });
await expect(modal).toBeVisible();
await expect(page.getByRole('button', { name: 'Keep project' }))
.toBeFocused();
await page.keyboard.press('Escape');
await expect(modal).toBeHidden();
await expect(opener).toBeFocused();
});
Automated checks should be paired with at least one screen-reader pass on the browser and operating-system combinations your audience uses. Listen for the title, concise description, control names, status updates, and restored context after closing.
Troubleshoot Common Dialog Bugs
showModal() throws InvalidStateError
The dialog may already be open non-modally with show() or the open attribute. Keep one state owner and guard repeated actions with if (!dialog.open) dialog.showModal().
The dialog sits under a transformed container
A modal opened with showModal() belongs to the top layer and should not be constrained by ordinary stacking contexts. If it still looks wrong, check whether you are toggling open instead of calling showModal(), or whether application CSS overrides the dialog's display.
Escape closes the dialog but triggers the wrong action
Never interpret an empty or stale returnValue as confirmation. Reset it before opening and require the exact confirm value.
Focus lands on the destructive button
Choose the initial focus deliberately with autofocus, based on the task. For irreversible actions, prefer the least destructive option.
Background content scrolls on mobile
Inertness blocks interaction, but scroll behavior can vary with layout and browser. Test real devices. If you add scroll locking, preserve the page's position and remove the lock on every close path, including Escape and errors.
Production Checklist
- Use a modal only when the workflow genuinely requires a blocking decision.
- Open native modals with
showModal(), not by toggling theopenattribute. - Give the dialog a visible title connected with
aria-labelledby. - Use
aria-describedbyonly for short, simple descriptive content. - Put initial focus on the control or static element that best supports the task.
- Include a visible close or cancel button and preserve Escape behavior.
- Treat only an explicit return value as confirmation.
- Restore focus to the opener or a logical next element after closing.
- Keep content usable at 200% zoom and on small dynamic viewports.
- Maintain a clear
:focus-visibleindicator and sufficient contrast. - Provide a tested fallback for environments without
showModal(). - Test keyboard, screen reader, touch, error, and repeated-open paths.
Official Sources
- MDN: the HTML dialog element
- WHATWG HTML Standard: interactive elements and dialogs
- WAI-ARIA Authoring Practices: modal dialog pattern
The native dialog is valuable because it gives you top-layer rendering, background inertness, keyboard dismissal, and focus behavior as browser primitives. Use those primitives instead of rebuilding them, then spend your effort on the parts only the product can decide: the right initial focus, clear language, safe actions, and a sensible place for users to continue.