An ongoing series on techniques that ship with the browser already - no library, no build step required.
A menu or a tooltip only ever needs to float above the page. A confirmation before you delete something needs more than that - it needs to actually stop you from doing anything else until you’ve dealt with it. That’s a different job, and it needs a different element.
Click the button. Notice you can’t click or tab to the page behind it:
HTML
<div class="dlg-wrap">
<button command="show-modal" commandfor="confirm-dialog" class="dlg-btn">
Delete project
</button>
<p class="dlg-status" aria-live="polite"></p>
<dialog id="confirm-dialog" class="dlg-modal">
<form method="dialog" class="dlg-form">
<h3>Delete this project?</h3>
<p>This can't be undone. The project and its files will be gone for good.</p>
<div class="dlg-actions">
<button value="cancel">Cancel</button>
<button value="confirm" class="dlg-confirm">Delete</button>
</div>
</form>
</dialog>
</div>CSS
.dlg-btn {
padding: 0.6rem 1.1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-white);
background: var(--color-gray-900);
border: 0;
cursor: pointer;
}
.dlg-status {
margin: 0.6rem 0 0;
font-size: 0.8rem;
color: var(--color-gray-500);
min-height: 1.2em;
}
.dlg-modal {
margin: auto; /* the site's CSS reset zeroes margin, which is what a
dialog's own centering relies on - restore it here explicitly. */
padding: 0;
border: 1px solid var(--color-gray-200);
/* A <dialog> isn't styled by this site's own light/dark variables by
default - left alone it follows the OS color scheme instead, which
can silently mismatch whatever theme the page itself is showing. */
background: var(--color-white);
color: var(--color-gray-900);
max-width: 320px;
width: 90%;
}
.dlg-modal::backdrop {
background: rgb(0 0 0 / 0.5);
}
.dlg-form {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dlg-form h3 {
margin: 0;
font-size: 1.05rem;
color: var(--color-gray-900);
}
.dlg-form p {
margin: 0;
font-size: 0.88rem;
color: var(--color-gray-500);
line-height: 1.5;
}
.dlg-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.dlg-actions button {
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--color-gray-300);
background: var(--color-white);
color: var(--color-gray-700);
cursor: pointer;
}
.dlg-confirm {
background: #b91c1c;
border-color: #b91c1c;
color: white;
}JS
(function () {
var dialog = document.getElementById("confirm-dialog");
var trigger = document.querySelector(".dlg-btn");
var status = document.querySelector(".dlg-status");
if (!dialog || !trigger) return;
// Fallback only: a browser without the command attribute just ignores
// it, so the button would otherwise do nothing. This isn't the
// technique - it's a safety net for the one gap that's left.
if (!("command" in trigger)) {
trigger.addEventListener("click", function () {
dialog.showModal();
});
}
// Also just for the demo: reporting which button closed it. The close
// itself needed none of this.
dialog.addEventListener("close", function () {
if (status && dialog.returnValue) {
status.textContent =
'Closed via "' + dialog.returnValue + '" - read straight off dialog.returnValue, nothing tracked by hand.';
}
});
})();Cancel, confirm, click outside, or hit Escape - all four close it, and none of them needed a line of application code to make that happen.
The whole idea
Two pieces, and they don’t touch each other with JavaScript at all:
<button command="show-modal" commandfor="my-dialog">Open</button>
<dialog id="my-dialog">
<form method="dialog">
<p>Are you sure?</p>
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
command="show-modal" on the button, paired with commandfor pointing at the dialog’s id, is what opens it. The browser reads that pair natively and runs the equivalent of dialog.showModal() itself - no click handler required. Inside, <form method="dialog"> is what closes it: submitting that form - which any plain button inside it does by default - closes the nearest dialog and sets its returnValue to whichever button’s value triggered the submit. Both directions, declarative.
What a real modal gives you that a popover doesn’t
This is the part popover explicitly opts out of, and it’s worth being specific about what you get back by reaching for <dialog> instead:
- Focus is trapped. Tab cycles through the dialog’s own controls and nowhere else. You can’t accidentally tab out to a button behind it.
- Everything else goes
inert. The rest of the page can’t be clicked, focused, or found by a screen reader while the dialog is open - automatically, not because you disabled anything by hand. - You get a real backdrop.
::backdropis a pseudo-element the browser inserts behind the dialog, covering the whole viewport, and you can style it like anything else:
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
}
None of that exists for a popover, on purpose - a menu shouldn’t trap your focus or dim the page behind it. A confirmation dialog should do exactly that, and now it can without you writing the mechanism.
One thing that list doesn’t include: scrolling. By default, the page behind an open dialog still scrolls with the trackpad or mouse wheel just fine - showModal() traps focus and clicks, but it doesn’t touch the document’s own scrolling. If that matters for what you’re building, it’s still on you to handle, the same way it always was.
The one method name worth remembering
<dialog> has two ways to open, and only one of them is a true modal. dialog.show() - or just the open attribute sitting in your HTML - displays it in the normal page flow: no backdrop, no focus trap, nothing inert. dialog.showModal(), or command="show-modal" doing the same thing declaratively, is the one that turns on everything above. Reach for the wrong one and the dialog appears but nothing else changes, which is a confusing bug to track down the first time it happens to you.
Two more bugs, both from the same cause
<dialog> isn’t wired into your site’s own design system just by existing - it renders with the browser’s own defaults until you tell it otherwise, and two of those defaults are easy to trip over.
The centering is the first one. It’s just margin: auto under the hood, and almost every CSS reset in common use, this site’s included, zeroes out margin on everything. Reset first, and the centering silently breaks: the dialog still opens, still traps focus, still does everything else right, and just sits pinned in the top-left corner looking broken.
The second is color. Left alone, a <dialog> follows the visitor’s OS-level light/dark preference, not whatever theme your page happens to be showing - so on a site like this one, with its own light/dark toggle, an unstyled dialog can open in a dark box on a light page, or the reverse, depending on what the visitor’s system is set to. It has nothing to do with your site’s own .dark class; the two systems just don’t talk to each other by default.
Both are the same one-line fix - give the dialog real values instead of leaving it to guess:
dialog {
margin: auto;
background: var(--color-white);
color: var(--color-gray-900);
}
Everything else - the backdrop, the trap, the inert background - comes from the browser regardless of any of this. Only appearance depends on values your reset or your color scheme already took a default position on.
Where it’s supported
The element itself has been safe to build on for years:
Browser support · <dialog> element
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
command and commandfor - the part that opens it with no JavaScript at all - are a different story. This is the newest technique that’s appeared in this collection so far:
Browser support · command / commandfor
Support data from the spec via browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
It’s in every current engine, which is what makes it worth teaching as the default rather than a curiosity - but it’s recent enough everywhere that a visitor on an older browser is a real possibility, not a rounding error. That’s what the fallback script in the demo above is for: it checks "command" in trigger once, and only wires up an old-fashioned dialog.showModal() click handler if the attribute genuinely isn’t there. Everyone gets a working dialog. Newer browsers just don’t need the script to get one.
Closing the loop on the last post
If <details> is the platform’s answer to a disclosure widget, and this site’s popover post covered its answer to a floating one, <dialog> is the platform’s answer to the one job neither of those will do: stopping the visitor until they’ve dealt with something. Between the three, it’s getting hard to find a common UI pattern that still has a good reason to start from a component library instead of the element that was already built for it.



