Modal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
<div class="demo">
<button class="btn" id="open">Delete this project</button>
<p class="note">The native <code><dialog></code> element handles the backdrop, the focus trap
and Escape on its own.</p>
</div>
<dialog id="confirm" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title">Delete “Spring catalogue”?</h2>
<p>This removes the project and its 48 files. It cannot be undone.</p>
<div class="actions">
<button class="btn secondary" value="cancel">Keep it</button>
<button class="btn danger" value="delete" autofocus>Delete project</button>
</div>
</form>
</dialog>
<p class="result" id="result" role="status"></p>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 28px; }
.demo { display: grid; gap: 12px; justify-items: start; }
.note { font-size: .86rem; color: #8593ab; }
.note code { background: #eef1f6; padding: 1px 5px; border-radius: 4px; font-size: .92em; }
.result { margin-top: 14px; font-size: .9rem; color: #1f8a5b; }
/* ============ the dialog ============ */
dialog {
width: min(420px, calc(100vw - 32px));
padding: 26px 28px 24px;
border: 1px solid #e2e7ef;
border-radius: 14px;
background: #fff;
color: #16202e;
box-shadow: 0 4px 8px rgba(22,32,46,.06), 0 24px 56px -28px rgba(22,32,46,.6);
}
dialog::backdrop {
background: rgba(16, 27, 45, .45);
backdrop-filter: blur(2px);
}
dialog h2 { font-size: 1.2rem; margin-bottom: 8px; }
dialog p { color: #5b6b83; font-size: .95rem; }
dialog .actions { margin-top: 22px; display: flex; gap: 10px; justify-content: flex-end; }
/* entry animation, using allow-discrete so display can be transitioned */
dialog, dialog::backdrop { transition: opacity .18s ease, display .18s allow-discrete; opacity: 0; }
dialog[open], dialog[open]::backdrop { opacity: 1; }
@starting-style {
dialog[open], dialog[open]::backdrop { opacity: 0; }
}
/* ============ buttons ============ */
.btn {
font: inherit;
font-size: .93rem;
font-weight: 500;
padding: 10px 20px;
min-height: 40px;
border: 1px solid #3b4fe4;
border-radius: 9px;
background: #3b4fe4;
color: #fff;
cursor: pointer;
}
.btn:hover { background: #2a3abf; border-color: #2a3abf; }
.btn:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 2px; }
.btn.secondary { background: #fff; color: #16202e; border-color: #d9e0ea; }
.btn.secondary:hover { background: #f2f5f9; }
.btn.danger { background: #c93b3b; border-color: #c93b3b; }
.btn.danger:hover { background: #a82f2f; border-color: #a82f2f; }
.btn.danger:focus-visible { outline-color: #c93b3b; }
@media (prefers-reduced-motion: reduce) {
dialog, dialog::backdrop { transition: none; }
}const dialog = document.getElementById("confirm");
const result = document.getElementById("result");
document.getElementById("open").addEventListener("click", () => {
dialog.showModal(); // modal: focus is trapped, the rest is inert
});
// fires on submit, on Escape, and on close()
dialog.addEventListener("close", () => {
result.textContent =
dialog.returnValue === "delete" ? "Project deleted." : "Nothing was deleted.";
});
// clicking the backdrop closes it — the dialog's own box is the click target,
// so a click landing on the element itself means the backdrop was hit
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close("cancel");
});How it works
Almost every modal on the web is a div with a few hundred lines of JavaScript behind it. The browser has had a purpose-built element for this since 2022 and it does the hard parts correctly.
showModal() gives you four things for free. A focus trap, so Tab cannot escape the dialog. The rest of the page marked inert, so a screen reader cannot read behind it. Escape to close. And a top-layer stacking context, so the dialog is above everything regardless of z-index — which is the bug that makes hand-rolled modals appear behind a sticky header.
::backdrop is a real pseudo-element. No overlay div, no fixed positioning, no scroll-lock hack. It is part of the element and it is always exactly behind it.
method="dialog" on the form closes the dialog on submit without navigating anywhere, and sets returnValue to the pressed button's value. That is how you tell confirm from cancel without any extra listeners.
The close event fires for every path out — the buttons, Escape, and a programmatic close(). One listener covers all of them, which is where hand-rolled modals usually leak state.
The backdrop click check. e.target === dialog is true only when the click landed on the dialog element's own box rather than on any child. Because the padding belongs to the dialog, a click on the padding also closes it — if that bothers you, wrap the contents in a div and test against that instead.
@starting-style and allow-discrete are what let a display: none element fade in. Without them the dialog appears instantly. Browsers that do not support them simply skip the animation.
Accessibility notes
aria-labelledby points at the heading, which gives the dialog its accessible name. Without it a screen reader announces "dialog" with no indication of what it is asking.
autofocus on the confirm button sets the initial focus. For a destructive action, consider putting it on the cancel button instead so the dangerous option is not one Enter press away.
When the dialog closes, focus returns automatically to the element that opened it. That is browser behaviour, and it is the single most commonly missed step in a custom modal.
The result message uses role="status" so the outcome is announced after the dialog goes away.
Making it yours
show() instead of showModal() gives a non-modal dialog: no backdrop, no focus trap, the rest of the page stays usable. That is the right call for a toolbar popover, and the wrong one for a confirmation.
For a drawer rather than a centred box, set margin: 0 0 0 auto; height: 100dvh; max-height: none; border-radius: 14px 0 0 14px and it slides against the right edge.
<dialog> is supported in every current browser. If you must support something older, the polyfill approximates the behaviour — but the focus trap is the part that is hardest to get right by hand, so test it.
Related templates
Button set
Primary, secondary, ghost and danger, in three sizes, with every state covered.
HTMLCSSTailwindLoaders and skeletons
Six loading indicators in pure CSS, plus the skeleton pattern that usually beats all of them.
HTMLCSSAccordion and FAQ
Built on details and summary — open, close and keyboard support with no JavaScript.
HTMLCSSTooltips
CSS-only tooltips on four sides, plus the anchor-positioning version.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.