dialog
On what modal actually means, the form integration nobody knows about, and the one behavior you still have to write yourself.
In April I wrote about the popover attribute and said a popover is not a modal, and that there’s a separate element for that which has its own set of things people get wrong. This is that post.
Four modals, four projects, one per couple of years. I went back and looked at all four. The first had no focus trap. The second trapped focus and never returned it to the trigger. The third did both and left the page behind it scrollable and reachable by screen reader. The fourth was fine and was 300 lines.
What “modal” actually means
The word gets used to mean “a box in the middle of the screen,” and the actual meaning is about the rest of the page.
A modal dialog demands to be dealt with. While it’s open, nothing behind it should be interactive, focusable, or announced. That’s the difference from a popover, where the page underneath stays live and clicking away dismisses it.
showModal() gives you all of that:
<button id="open">Delete account</button>
<dialog id="confirm">
<h2>Delete this account?</h2>
<p>This removes all their orders. It can't be undone.</p>
<form method="dialog">
<button value="cancel" autofocus>Keep it</button>
<button value="delete" class="button--danger">Delete</button>
</form>
</dialog>
<script>
open.addEventListener('click', () => confirm.showModal());
confirm.addEventListener('close', () => {
if (confirm.returnValue === 'delete') deleteTheAccount();
});
</script>
That gets you: rendering in the top layer, so no stacking-context bugs. Everything outside made inert. Focus moved in and returned to the trigger on close. Escape closing it. A ::backdrop pseudo-element for the overlay. All six of the things my four modals were failing at, in one method call.
Four hand-rolled modals across ten years, and each one got a different two of the six behaviors wrong. The element gets all six.
Note autofocus on the safe option. When a modal opens, focus goes to the first focusable element unless you say otherwise, and on a destructive confirmation that means somebody hitting Enter twice quickly can delete an account. Put it on the cancel.
The form method nobody knows
The bit that surprised me most, and it’s been there the whole time.
<form method="dialog"> inside a dialog doesn’t submit anywhere. It closes the dialog and sets returnValue to the value of whichever button was pressed.
Which means the entire “which button did they press” plumbing is markup. No click handlers on each button, no state variable, no callback threading. One close listener that reads a string.
I’d used <dialog> for two years before I noticed this, because every example anywhere uses buttons with click handlers, and it turns a component into markup.
Closing on backdrop click isn’t free
The one behavior you still write yourself: clicking outside the dialog doesn’t close it. That’s arguably correct for something demanding a decision, and it’s the convention people expect for anything lighter.
The implementation is a nice piece of trivia. The backdrop isn’t a separate element you can listen on, but clicks on it target the <dialog> itself, whereas clicks on your content target your content. So:
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close(); // only true for the backdrop
});
Which requires that the dialog’s own padding is zero and the content sits in a wrapper inside it, otherwise the padding area counts as the dialog and closing feels random.
The gotchas
Don’t use the open attribute. Putting open on a <dialog> in the markup shows it, and shows it non-modally: no top layer, no backdrop, no inert page, no focus handling. It looks like the element is broken. Always open it with showModal().
Don’t override display. Same as popover. The browser’s own styles use display: none when closed, and setting display: flex on the dialog element makes it permanently visible. Layout goes on a wrapper inside.
Escape fires cancel before close, and cancel is preventable, which is how you implement “are you sure you want to discard your changes” on a form dialog.
Check the background scrolling. Behavior here has been inconsistent between browsers, and plenty of teams still lock scroll on the body while a dialog is open. I would test it rather than assume, particularly on iOS.
Animating it needs the same handling as the popover: transitioning something out of display: none requires the newer discrete-transition machinery, which isn’t everywhere yet. A fade in is easy, a fade out is fiddly, and the honest answer for now on client work is often to not animate the close.
Should it be a dialog at all
The question worth asking before any of the above, and the one I keep forgetting.
Most confirmation dialogs shouldn’t exist. “Are you sure?” is a mechanism that interrupts everybody in order to catch the small proportion who made a mistake, and after about the fourth one, people stop reading them and click through automatically, which means it has stopped catching anything and is now pure friction.
Undo is nearly always better. Do the thing, say what you did, offer to reverse it for 30 seconds. It’s faster for everybody who meant it, it genuinely rescues the people who didn’t, and it doesn’t train anyone to dismiss warnings.
Where a confirmation is right is where undo is impossible: sending an email, processing a payment, anything with an external side effect. And there, the copy is the whole design, which is a post I wrote in 2019 and stand by. “Are you sure?” with OK and Cancel tells nobody anything. “Delete this account? This removes all their orders and can’t be undone” with a button that says Delete is a decision somebody can actually make.
Why I never went looking
The fourth modal was 300 lines and about nine hours of work spread over two years of bug reports.
The element has been in every browser since 2022, and in one of them since 2014, and I built two of those four after that. I don’t have a good explanation, beyond the general one: I knew how to build a modal, so I never went looking for whether I still needed to 🫠