An overlay that grabs focus and blocks interaction with the rest of the page until it’s dismissed. The native <dialog> element does most of the hard parts — focus trapping, Escape-to-close, a backdrop — for free.
Pseudocode
Structure
<dialog> wraps the modal’s content. Unlike everything else covered so far, it’s invisible and inert until opened from JS — there’s no pure-CSS way to open one.
Style
The browser renders an open <dialog> centered on top of everything, with a ::backdrop pseudo-element covering the rest of the page — both are stylable.
Features
dialog.showModal() opens it (and traps focus, and blocks the rest of the page); dialog.close() closes it.
Escape already closes it natively. Clicking the backdrop doesn’t, by default — that’s a couple of lines of JS, checking whether the click landed on the <dialog> element itself or inside its content.
A <form method="dialog"> inside it closes the dialog on submit with no JS at all, and records which button was clicked.
const openBtn = document.getElementById("md-open");const dialog = document.getElementById("md-dialog");const cancelBtn = document.getElementById("md-cancel");const confirmBtn = document.getElementById("md-confirm");if (openBtn && dialog && cancelBtn && confirmBtn) { openBtn.onclick = () => dialog.showModal(); // traps focus and blocks the page, for free cancelBtn.onclick = () => dialog.close(); confirmBtn.onclick = () => dialog.close(); dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); // click landed on the backdrop, not the content });}
Modal with a form (native close, no handler JS)
<form method="dialog"> closes its <dialog> on submit by itself — any button inside it (they default to type="submit") closes the dialog without any JS, and sets dialog.returnValue to whichever button’s value was clicked. JS is only needed to open the dialog and to read the result afterward, via the dialog’s close event.