DEV Community

Cover image for You built your modal with a `<div>` and a focus trap library. The native `<dialog>` does all of that.
Parsa Jiravand
Parsa Jiravand

Posted on

You built your modal with a `<div>` and a focus trap library. The native `<dialog>` does all of that.

Building a modal from scratch means writing the same boilerplate every time: a <div> with role="dialog", an aria-modal attribute, a tabindex="-1" to steal focus, a keydown listener to catch Escape, a focus-trap library to keep Tab cycling inside the dialog, and a click listener on the backdrop overlay you rendered yourself. It works, but you're doing the browser's job.

The <dialog> element exists to take that job back.

The baseline API

<dialog id="confirm-dialog">
  <h2>Delete item?</h2>
  <p>This cannot be undone.</p>
  <button id="cancel-btn">Cancel</button>
  <button id="confirm-btn">Confirm</button>
</dialog>

<button id="open-btn">Delete</button>
Enter fullscreen mode Exit fullscreen mode
const dialog = document.getElementById('confirm-dialog');
const openBtn = document.getElementById('open-btn');
const cancelBtn = document.getElementById('cancel-btn');
const confirmBtn = document.getElementById('confirm-btn');

openBtn.addEventListener('click', () => dialog.showModal());
cancelBtn.addEventListener('click', () => dialog.close());
confirmBtn.addEventListener('click', () => {
  deleteItem();
  dialog.close();
});
Enter fullscreen mode Exit fullscreen mode

That's it. The browser handles focus trapping (Tab stays inside), Escape-to-close, and ARIA role. No library. No keydown listener. No backdrop div.

showModal() vs show()

The element has two open methods and they are meaningfully different.

dialog.show() opens the dialog as a non-modal: it's visible, but the rest of the page is still interactive. Useful for inline panels, drawers, or toasts — not for blocking confirmation flows.

dialog.showModal() opens it as a modal: the browser places the dialog in the top layer — above all other content, including elements with high z-index — and blocks interaction with everything beneath. Focus is trapped inside. Escape closes it. This is what you actually want for a modal dialog.

dialog.show();      // non-modal — rest of page still interactive
dialog.showModal(); // modal — top layer, focus trapped, Escape works
Enter fullscreen mode Exit fullscreen mode

Styling the backdrop

When opened with showModal(), the browser renders a backdrop behind the dialog and above the rest of the page. You style it with the ::backdrop pseudo-element:

dialog::backdrop {
  background: rgb(0 0 0 / 50%);
  backdrop-filter: blur(4px);
}
Enter fullscreen mode Exit fullscreen mode

No more position: fixed; inset: 0; background: rgba(0,0,0,0.5) divs. The browser-rendered backdrop is always in the right place, covers the right things, and is animated by the dialog's own open/close transitions.

Animating open and close

The <dialog> element pairs naturally with @starting-style for entry animations. For the exit, you need a small JS helper because the dialog's close() method removes the open attribute before a CSS exit transition can play:

dialog {
  opacity: 0;
  transform: scale(0.95);
  transition: opacity 200ms, transform 200ms, display 200ms allow-discrete;
}

dialog[open] {
  opacity: 1;
  transform: scale(1);
}

@starting-style {
  dialog[open] {
    opacity: 0;
    transform: scale(0.95);
  }
}
Enter fullscreen mode Exit fullscreen mode

With allow-discrete on the display transition (Chrome 117+), the browser holds the dialog in the layout for the duration of the exit transition before hiding it. For older targets, a small setTimeout before dialog.close() is the reliable fallback.

Handling backdrop clicks to close

The backdrop is not a separate element you can listen to directly. The reliable pattern uses the dialog's own click event and checks whether the click landed inside the dialog's bounding box:

dialog.addEventListener('click', (event) => {
  const rect = dialog.getBoundingClientRect();
  const clickedOutside =
    event.clientX < rect.left ||
    event.clientX > rect.right ||
    event.clientY < rect.top ||
    event.clientY > rect.bottom;

  if (clickedOutside) dialog.close();
});
Enter fullscreen mode Exit fullscreen mode

The reason this works: when you click the backdrop, the <dialog> element itself receives the event — the click target is the dialog, not an element inside it. The bounding box check distinguishes backdrop clicks from content clicks cleanly.

Return value from close()

dialog.close() accepts an optional string argument that becomes the dialog's returnValue. This lets you communicate why the dialog closed without external state:

confirmBtn.addEventListener('click', () => dialog.close('confirmed'));
cancelBtn.addEventListener('click', () => dialog.close('cancelled'));

dialog.addEventListener('close', () => {
  if (dialog.returnValue === 'confirmed') deleteItem();
});
Enter fullscreen mode Exit fullscreen mode

The close event fires whenever the dialog closes — via close(), Escape, or a <form method="dialog"> submission. Combined with returnValue, it gives you a clean interface for confirmation flows without threading state through callbacks.

The <form method="dialog"> shortcut

If you put a <form method="dialog"> inside a <dialog>, any submit button closes the dialog automatically and sets returnValue to the button's value attribute — no JavaScript needed for the close logic:

<dialog id="confirm-dialog">
  <form method="dialog">
    <h2>Delete item?</h2>
    <button value="cancelled">Cancel</button>
    <button value="confirmed">Confirm</button>
  </form>
</dialog>
Enter fullscreen mode Exit fullscreen mode
dialog.addEventListener('close', () => {
  if (dialog.returnValue === 'confirmed') deleteItem();
});
Enter fullscreen mode Exit fullscreen mode

The form doesn't submit to a server — method="dialog" is a special value that routes the submission back to the dialog element itself. Useful for simple flows; for complex forms with validation, you'll still handle submission explicitly.

Browser support

<dialog> is Baseline 2022: Chrome 98, Firefox 98, Safari 15.4. Global support is above 95%. You do not need a polyfill for production today. The only missing piece in some older browsers is ::backdrop styling — the functional modal behavior (focus trap, Escape, top layer) is universally supported.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

Search your codebase for role="dialog" and the focus-trap imports that live near it. Each one is a candidate for replacement: <dialog> with showModal() handles focus trapping, Escape, backdrop, and ARIA semantics — a library bought you those because the platform didn't provide them. It does now. Start with the simplest confirmation dialog in your UI, replace it with <dialog> and showModal(), and you'll notice what disappears: the keydown listener, the backdrop div, the z-index war, and the focus-management ceremony. What remains is the logic that was actually yours to write.


Thanks for reading! Let's stay connected:

Top comments (0)