HTML Formatterhtmlformatteronline.com

Modal dialog

A modal built on the native dialog element — focus trap and backdrop included, for free.

cssmodaldialogpopupaccessibility
<div class="demo">
  <button class="btn" id="open">Delete this project</button>
  <p class="note">The native <code>&lt;dialog&gt;</code> element handles the backdrop, the focus trap
  and Escape on its own.</p>
</div>

<dialog id="confirm" aria-labelledby="confirm-title">
  <form method="dialog">
    <h2 id="confirm-title">Delete “Spring catalogue”?</h2>
    <p>This removes the project and its 48 files. It cannot be undone.</p>

    <div class="actions">
      <button class="btn secondary" value="cancel">Keep it</button>
      <button class="btn danger" value="delete" autofocus>Delete project</button>
    </div>
  </form>
</dialog>

<p class="result" id="result" role="status"></p>

How it works

Almost every modal on the web is a div with a few hundred lines of JavaScript behind it. The browser has had a purpose-built element for this since 2022 and it does the hard parts correctly.

showModal() gives you four things for free. A focus trap, so Tab cannot escape the dialog. The rest of the page marked inert, so a screen reader cannot read behind it. Escape to close. And a top-layer stacking context, so the dialog is above everything regardless of z-index — which is the bug that makes hand-rolled modals appear behind a sticky header.

::backdrop is a real pseudo-element. No overlay div, no fixed positioning, no scroll-lock hack. It is part of the element and it is always exactly behind it.

method="dialog" on the form closes the dialog on submit without navigating anywhere, and sets returnValue to the pressed button's value. That is how you tell confirm from cancel without any extra listeners.

The close event fires for every path out — the buttons, Escape, and a programmatic close(). One listener covers all of them, which is where hand-rolled modals usually leak state.

The backdrop click check. e.target === dialog is true only when the click landed on the dialog element's own box rather than on any child. Because the padding belongs to the dialog, a click on the padding also closes it — if that bothers you, wrap the contents in a div and test against that instead.

@starting-style and allow-discrete are what let a display: none element fade in. Without them the dialog appears instantly. Browsers that do not support them simply skip the animation.

Accessibility notes

aria-labelledby points at the heading, which gives the dialog its accessible name. Without it a screen reader announces "dialog" with no indication of what it is asking.

autofocus on the confirm button sets the initial focus. For a destructive action, consider putting it on the cancel button instead so the dangerous option is not one Enter press away.

When the dialog closes, focus returns automatically to the element that opened it. That is browser behaviour, and it is the single most commonly missed step in a custom modal.

The result message uses role="status" so the outcome is announced after the dialog goes away.

Making it yours

show() instead of showModal() gives a non-modal dialog: no backdrop, no focus trap, the rest of the page stays usable. That is the right call for a toolbar popover, and the wrong one for a confirmation.

For a drawer rather than a centred box, set margin: 0 0 0 auto; height: 100dvh; max-height: none; border-radius: 14px 0 0 14px and it slides against the right edge.

<dialog> is supported in every current browser. If you must support something older, the polyfill approximates the behaviour — but the focus trap is the part that is hardest to get right by hand, so test it.

Related templates

Check your version

Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.