For modals, use the <dialog> element rather than a hand-rolled div. The browser handles focus trapping, making the background inert, dismissing with Escape, and styling the backdrop through ::backdrop.
<dialog> became usable in every modern browser in March 2022 and is now Baseline (widely available). Since then, two additions have closed the remaining gaps: the closedby attribute for controlling how a dialog can be dismissed, and @starting-style for animating it.
This article starts from the difference between show() and showModal() and goes through the parts that actually cause trouble: closing on outside click, animating the close, and reading back a result.
Sponsored
show() vs showModal()
In almost every situation the one you want is showModal(). With show(), the browser protects nothing for you.
showModal() |
show() |
|
|---|---|---|
| Background interaction | Blocked (effectively inert) | Allowed |
| Focus trapping | Yes | No |
| Escape closes it | Yes (cancel event) |
No |
::backdrop |
Applies | Does not apply |
| ARIA | aria-modal="true" |
aria-modal="false" |
| Stacking | Top layer (ignores z-index) |
Normal flow |
show() is only right for a panel you want to consult while continuing to use the page—a filter panel, a chat window. In that case you have to implement Escape handling yourself.
A non-modal dialog
See the Pen
dialog01 by Rin (@rinblog0408)
on CodePen.
<button id="open-dialog">Open non-modal dialog</button>
<dialog id="non-modal-dialog">
<p>This is non-modal. You can still use the page behind it.</p>
<button id="close-dialog" autofocus>Close</button>
</dialog>
<script>
const dialog = document.getElementById('non-modal-dialog');
const openButton = document.getElementById('open-dialog');
const closeButton = document.getElementById('close-dialog');
openButton.addEventListener('click', () => {
dialog.show(); // non-modal
});
closeButton.addEventListener('click', () => {
dialog.close();
});
// non-modal dialogs do not close on Escape — do it yourself
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dialog.open) dialog.close();
});
</script>
That last Escape handler is mandatory for non-modal dialogs. Without it, keyboard-only users have no way to dismiss the dialog.
Sponsored
A modal dialog
See the Pen
dialog02 by Rin (@rinblog0408)
on CodePen.
<button id="open-modal-dialog">Open modal dialog</button>
<dialog id="modal-dialog">
<h2>Confirm</h2>
<p>This is modal. The page behind it cannot be used until you close it.</p>
<button id="close-modal-dialog" autofocus>Close</button>
</dialog>
<script>
const modalDialog = document.getElementById('modal-dialog');
document.getElementById('open-modal-dialog')
.addEventListener('click', () => modalDialog.showModal());
document.getElementById('close-modal-dialog')
.addEventListener('click', () => modalDialog.close());
</script>
Note the autofocus. Without it, focus lands on the first focusable element in the dialog. If a destructive button such as “Delete” comes first, always put autofocus on the safe option instead—otherwise repeated Enter presses cause accidents.
Do not put tabindex on the <dialog> itself; the container is not an interactive element.
Closing on outside click: the closedby attribute
“Close when the user clicks outside” is the most requested behaviour, and it does not happen by default. One attribute enables it: closedby.
<dialog id="dlg" closedby="any">
<p>Closes on outside click and on Escape</p>
<button autofocus>Close</button>
</dialog>
| Value | How it can be closed |
|---|---|
none |
Only your buttons or close(). Escape does nothing |
closerequest |
Escape plus your own mechanisms (the default for showModal()) |
any |
Light dismiss (outside click) plus Escape plus your own |
As of September 2026, closedby is supported in Chrome, Edge, and Firefox, but not Safari (it is an Interop 2026 focus area). For now, ship a fallback alongside it.
const dlg = document.getElementById('dlg');
// fallback for browsers without closedby
if (!('closedBy' in dlg)) {
dlg.addEventListener('click', (e) => {
// a click on the dialog itself means the backdrop area
if (e.target === dlg) dlg.close();
});
}
This works because clicks outside the dialog’s box (the backdrop region) arrive as events on the <dialog> element itself. Wrap the contents in a single <div> so that inner clicks cannot accidentally close it.
Conversely, for a form with unsaved input you can set closedby="none" so nothing but an explicit Cancel button closes it.
Sponsored
Reading a result back with method=”dialog”
Give a form inside a <dialog> the attribute method="dialog" and submitting closes the dialog without sending anything, putting the pressed button’s value into returnValue. Confirmation dialogs become very short.
<dialog id="confirm-dialog">
<form method="dialog">
<p>Delete this post?</p>
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
const confirmDialog = document.getElementById('confirm-dialog');
confirmDialog.addEventListener('close', () => {
if (confirmDialog.returnValue === 'delete') {
deletePost();
}
});
confirmDialog.showModal();
There are two events, and mixing them up leads to handlers that never run:
close: fires whenever the dialog closes. ReadreturnValueherecancel: fires when a modal is dismissed with Escape.returnValueis not updated
After an Escape dismissal, returnValue is still an empty string. Writing the handler as “only act on a specific value”, as above, makes Escape fail safe automatically.
Animating open and close
<dialog> moves between display: none and display: block, so a plain transition does nothing. You need @starting-style and transition-behavior: allow-discrete.
dialog {
opacity: 0;
transform: translateY(-16px);
transition:
opacity .3s ease-out,
transform .3s ease-out,
overlay .3s ease-out allow-discrete,
display .3s ease-out allow-discrete;
}
dialog:open {
opacity: 1;
transform: translateY(0);
}
@starting-style {
dialog:open {
opacity: 0;
transform: translateY(-16px);
}
}
dialog::backdrop {
background: rgb(0 0 0 / 0);
transition: background .3s ease-out, overlay .3s ease-out allow-discrete, display .3s ease-out allow-discrete;
}
dialog:open::backdrop {
background: rgb(0 0 0 / .5);
}
@starting-style {
dialog:open::backdrop { background: rgb(0 0 0 / 0); }
}
@media (prefers-reduced-motion: reduce) {
dialog, dialog::backdrop { transition: none; }
}
Three things to get right:
- Include
displayandoverlayin the transition withallow-discrete, or the close animation is skipped entirely - Declare the starting values in
@starting-style, or the open animation never plays - Always provide a
prefers-reduced-motionbranch
Scrolling and sizing issues
While a modal is open, the page behind it can still scroll. showModal() blocks interaction, not scrolling.
body:has(dialog[open]) {
overflow: hidden;
}
For long dialogs, cap the height on the dialog itself:
dialog {
max-width: min(90vw, 640px);
max-height: 85dvh;
overflow: auto;
border: none;
border-radius: 12px;
padding: 24px;
}
Using dvh avoids the bottom of the dialog being cut off as mobile address bars expand and collapse.
Summary
- Use
showModal().show()gives you no focus trap, no Escape, no::backdrop - Put
autofocuson the safe button. Nevertabindexon<dialog> - For outside-click dismissal use
closedby="any", with ane.target === dialogfallback for Safari - Read results with
<form method="dialog">andreturnValue; know the difference betweencloseandcancel - Animate with
@starting-styleplustransition-behavior: allow-discrete, includingdisplayandoverlay - Stop background scrolling with
body:has(dialog[open]) { overflow: hidden; }
Focus management and Escape handling are the hardest parts of a hand-built modal, and <dialog> takes both off your hands. For comparison, I previously wrote about building a modal window with jQuery—useful for seeing how much the element removes.