HTML2 min read

The dialog element gives you a focus trap for free

Hand rolled modals leak focus to the page behind them. The dialog element handles focus, Escape, the backdrop, and the top layer. Here is the small amount you still write.

Most hand written modals have the same three bugs. Tab moves focus to links behind the modal. Escape does nothing. Focus does not come back to the button that opened it.

<dialog> fixes all three, if you open it the right way.

The minimum that works

<button type="button" id="open">Delete account</button>

<dialog id="confirm">
  <form method="dialog">
    <h2>Delete account?</h2>
    <p>This cannot be undone.</p>
    <button value="cancel">Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
const dialog = document.getElementById("confirm");

document.getElementById("open").addEventListener("click", () => {
  dialog.showModal();
});

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "delete") deleteAccount();
});

What you get without writing it:

  • Focus moves into the dialog and cannot leave it while it is open.
  • Escape closes it and fires the close event.
  • Focus returns to the button that opened it.
  • Everything behind it becomes inert, so clicks and screen reader browsing do not reach it.
  • It renders in the top layer, above every stacking context, so no z-index battles.
  • ::backdrop gives you the dimmed layer.

method="dialog" on the form is what closes the dialog on submit and sets returnValue to the button's value.

showModal, not show

dialog.show() opens a non modal dialog. No focus trap, no inert page, no backdrop. dialog.showModal() is the one you almost always want. Setting the open attribute in HTML does the same thing as show(), so avoid that too.

Styling the backdrop and the box

dialog {
  border: 0;
  border-radius: 12px;
  padding: 1.5rem;
  max-width: min(32rem, 100% - 2rem);
  max-height: calc(100svh - 2rem);
  overflow-y: auto;
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
  backdrop-filter: blur(2px);
}

A modal dialog is centred by the browser. max-height plus overflow-y keeps a long dialog scrollable rather than clipped.

Closing when the backdrop is clicked

This is not built in. The trick is that a click on the backdrop has the dialog itself as its target, because the content sits inside a child element:

dialog.addEventListener("click", (event) => {
  if (event.target === dialog) dialog.close("cancel");
});

For this to be reliable, wrap the dialog content in a single element and put the padding on that wrapper rather than on the dialog. Otherwise a click on the dialog padding closes it, which feels wrong.

Stopping the page behind from scrolling

Also not built in:

body:has(dialog[open]) {
  overflow: hidden;
}

:has() is supported everywhere now, so you can do this with no JavaScript at all. Read it as "a body that contains an open dialog".

Choosing the first focused element

By default focus goes to the first focusable element in the dialog. For a destructive confirmation you usually want Cancel focused instead:

<button value="cancel" autofocus>Cancel</button>

autofocus inside a dialog applies each time it opens, not only on page load.

What about popover?

The Popover API covers the non modal cases: menus, tooltips, small overlays that should close when you click outside and should not trap focus.

<button popovertarget="menu">Options</button>
<div id="menu" popover>...</div>

Rule of thumb: if the user must answer before continuing, use showModal(). If they can ignore it and carry on, use a popover.

Written by Rahul Mourya, full stack engineer. Spotted a mistake or want the follow-up post? Email me.

All posts
  • HTML2 min read

    The viewport meta tag that breaks pinch to zoom

    user-scalable=no and maximum-scale=1 are copied into projects every day. They fail an accessibility audit, and the bug they were added to fix has another cause.

    • viewport
    • mobile
    • accessibility
  • HTML2 min read

    A data table a screen reader can actually read

    Four attributes turn a grid of numbers into a table that announces which row and column each cell belongs to. Plus how to make a wide table scroll without breaking it.

    • tables
    • accessibility
    • data
  • HTML2 min read

    What you lose when a div pretends to be a button

    A div with an onClick works for a mouse and fails for everyone else. Here is the full list of what the browser was doing for you, and what it costs to rebuild.

    • semantics
    • accessibility
    • buttons