The Modal Problem
Every project needs a modal at some point: a confirmation dialog, a settings panel, an image lightbox. And almost every project builds one from scratch. A div, a role attribute, a backdrop div, a z-index, a focus trap library, an ESC key listener, aria-hidden passed over everything else in the DOM.
That's a lot of code for something the browser already knows how to do. The <dialog> element has been Baseline widely available since 2022, when Firefox 98 and Safari 15.4 both shipped support within weeks of each other. Chrome had it since 2014. No polyfill required. The browser ships the whole thing.
What showModal() Gives You for Free
The critical method is showModal(). That one call does all of this automatically:
-
Focus management. Focus moves to the first focusable element inside the dialog on open, and returns to the trigger element on close. No tracking code, no manual
.focus()call. - Inert background. Everything outside the dialog becomes inert. Keyboard users cannot tab out. Pointer events are blocked. Screen readers cannot navigate to background content. The browser enforces this via the top layer.
-
ESC to close. The dialog fires a
cancelevent followed byclosewhen the user presses ESC. With multiple stacked modals, ESC closes only the topmost one — handled correctly by default. -
Top-layer placement. Dialogs opened with
showModal()live above all stacking contexts. No z-index calculation puts anything on top of them. The z-index arms race is over.
None of this requires a library. It is all platform-native behaviour.
The Minimal Implementation
Here is a working modal with open, close, backdrop dismiss, and result handling:
<button id="open-btn">Open modal</button>
<dialog id="confirm-modal" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm action</h2>
<p>Are you sure you want to do this?</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm" autofocus>Confirm</button>
</form>
</dialog>
<script>
const modal = document.getElementById('confirm-modal');
const openBtn = document.getElementById('open-btn');
openBtn.addEventListener('click', () => {
if (!modal.open) modal.showModal();
});
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.close();
});
// React to result
modal.addEventListener('close', () => {
if (modal.returnValue === 'confirm') {
console.log('User confirmed');
}
});
</script>
The form[method="dialog"] inside the dialog is doing real work here. It saves the form's state without submitting an HTTP request, and closes the dialog when a button is clicked. The clicked button's value attribute becomes dialog.returnValue, which you read in the close event listener.
The open and close mechanics of the buttons themselves require no JavaScript at all. form[method="dialog"] handles that declaratively.
The Most Common Use Case: Forms
Confirmation dialogs are a good introduction, but the most frequent real-world use of <dialog> is wrapping a form: a contact panel, a newsletter signup, a feedback prompt. A modal form gives you a focused interaction surface without navigating the user away from the page — and showModal() gives you the focus management, backdrop, and ESC handling for free.
If you are deploying on Forge, the form backend is already built in. Add a data-forge-name attribute to the <form> element and to each field you want captured — Forge reads these during deploy, creates the form endpoint, and handles submission, storage, and email notification. No server code, no API routes, no third-party form service.
<button id="contact-btn">Contact us</button>
<dialog id="contact-modal" aria-labelledby="contact-title">
<div class="modal-inner">
<button class="close-btn" aria-label="Close">×</button>
<h2 id="contact-title">Get in touch</h2>
<form data-forge-name="contact">
<label for="name">Name</label>
<input type="text" id="name" data-forge-name="name" required>
<label for="email">Email</label>
<input type="email" id="email" data-forge-name="email" required>
<label for="message">Message</label>
<textarea id="message" data-forge-name="message" required></textarea>
<button type="submit">Send</button>
</form>
</div>
</dialog>
<script>
const modal = document.getElementById('contact-modal');
const openBtn = document.getElementById('contact-btn');
const closeBtn = modal.querySelector('.close-btn');
openBtn.addEventListener('click', () => {
if (!modal.open) modal.showModal();
});
closeBtn.addEventListener('click', () => modal.close());
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.close();
});
</script>
data-forge-name="contact" on the form is how Forge identifies it at deploy time. Each field with a matching data-forge-name is captured on submit. Forge creates the endpoint and schema automatically — no configuration file, no dashboard setup. Add fields and redeploy; the schema updates to match.
This combination — <dialog> for the interaction layer, Forge forms for the submission layer — covers a large category of site interactions out of the box. The dialog handles focus, backdrop, and ESC. Forge handles the data. You write the markup.
Styling the Backdrop
The ::backdrop pseudo-element is created automatically when you use showModal(). Style it freely:
dialog::backdrop {
background: rgb(0 0 0 / 50%);
backdrop-filter: blur(4px);
}
dialog {
border: none;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 8px 32px rgb(0 0 0 / 20%);
max-width: 480px;
width: 100%;
/ Reset inconsistent browser defaults /
background: #fff;
}
Because the dialog lives in the top layer, the backdrop is always correctly positioned: full viewport, behind the dialog, above everything else in the page. No z-index management needed. Note that ::backdrop only appears with showModal(). The non-modal show() method produces no backdrop.
Accessibility, Already Handled
The <dialog> element carries an implicit role="dialog" — you do not need to add it manually. The showModal() inert behaviour means screen readers cannot navigate outside the open dialog. The focus trap is automatic.
Two things to add yourself:
-
aria-labelledbypointing to the dialog's headingid. Without it, a screen reader announces only "dialog" with no context. This is the most commonly missed accessibility requirement for custom modals. -
autofocuson the element the user should interact with first. In a confirmation dialog, that is usually the primary action button. Without it, focus lands on the first focusable element in DOM order, which may not be the right starting point.
The W3C ARIA Authoring Practices Guide covers the full modal dialog pattern. The native <dialog> satisfies nearly all of it without any extra work on your part.
One Gotcha Worth Knowing
Do not set position: relative on the <dialog> element itself. The default is position: fixed, and overriding it triggers an iOS bug where the modal opens off-screen. If you need to position a close button absolutely inside the dialog, wrap the content in a <div> and position relative to that wrapper instead:
<dialog id="confirm-modal" aria-labelledby="modal-title">
<div class="modal-inner">
<!-- position: relative and inner layout goes on this div -->
<button class="close-btn" aria-label="Close">×</button>
<h2 id="modal-title">Settings</h2>
<!-- content -->
</div>
</dialog>
One more: showModal() throws an InvalidStateError if the dialog is already open. Guard with if (!modal.open) before calling it, as shown in the example above.
What You No Longer Have to Write
Before <dialog> was production-ready, a correct modal required: a backdrop div with z-index management, role="dialog" on a generic div, aria-hidden="true" applied to every landmark outside the modal, a focus trap library like focus-trap, a manual keydown listener for ESC, trigger-element tracking with a manual .focus() call on close, and overflow: hidden on <body> for scroll lock.
All of it was error-prone, and the accessibility pieces were routinely shipped incomplete. The WebAIM Million survey consistently finds custom interactive widgets among the top sources of WCAG failures. The native element handles the hard parts correctly by default.
Start Here
The <dialog> element is one of those browser features that makes you realise how much JavaScript you have been writing to fill gaps the platform always intended to fill. Focus management, inert backgrounds, top-layer placement, accessible roles: none of this required a framework. It required the spec to catch up.
The spec caught up in 2022. MDN has the full reference and Can I Use shows 92% global coverage. Copy the demo above, drop the modal library, and move on.
This is the first post in a series on the native web. The browser has been shipping the tools you have been reaching for libraries to get. The rest of the series goes deeper: popovers, form validation, container queries, view transitions, and more. Start with the platform.