Made by Dhawal Made by Dhawal

The Dialog Gate: why your div modal lets Tab escape, and how <dialog> fixes it

Kai's modal is a div, so Tab walks straight out and Escape does nothing. Vishwa, the Earth guide of Domlok, swaps it for a native <dialog> opened with showModal(), and the browser handles focus, Escape and the page behind.

Told by Vishwa, with Kai asking the questions

The Hollow Wall, a grinning stone wall spirit with a door marked div painted on its chest, beside the title "The Dialog Gate"

Vishwa: A builder judges a gate by what it does, Kai, not by how it looks. Yours lets everyone walk straight through.

But it looks like a modal!

If keyboard users can Tab out of your modal onto the page behind it, and Escape does nothing, your modal is probably a styled <div>. Swap it for a native <dialog> opened with showModal(), and the browser gives you the focus handling, the Escape key, the backdrop and the inert page for free.

In the world of Domlok this is the work of the Hollow Wall, a nurikabe with a door painted on its chest. From the front it looks exactly like a gate. Walk up to it with a keyboard or a screen reader and you find there is nothing there: you pass straight through to whatever is behind.

Your guide for this episode is Vishwa, the Earth guide of Domlok and its builder. HTML and semantics are his domain: he cares what an element is, not just what it looks like, which makes him the right one to tell a painted door from a real gate. Kai, as usual, is the one who built the painted door.

The painted door

Kai needs a confirm box before the user leaves the dojo. He builds it the way many of us learned: an overlay, a box, a very large z-index, and a line of JavaScript to show it.

LeaveModal.html
<div class="overlay"></div>
<div class="modal" id="leave">
<h2>Leave the dojo?</h2>
<button id="stay">Stay</button>
<button id="go">Leave</button>
</div>

modal.js
// .modal { position: fixed; z-index: 9000; display: none; }
openBtn.onclick = () => { leave.style.display = 'block'; };

KaiIt sits on top of everything. Looks like a modal to me.

To a mouse user, it is. Now try it with a keyboard:

  1. Focus stays behind. Opening the box doesn't move focus into it. Focus is still on the button you clicked, underneath the overlay.
  2. Tab walks out. Tab moves to the next focusable element in the document, so after Stay and Leave it lands on links and inputs in the page behind, which you can't even see.
  3. Escape means nothing. A div has no idea it is supposed to close.
  4. Screen readers keep reading the page. Nothing announces a dialog, and the content behind the overlay is still in the accessibility tree.
  5. The z-index war. A z-index only competes inside its own stacking context, so a sticky header or a transformed parent can still end up on top of your box.

VishwaYou can patch every one of those with code: a focus trap, a keydown listener, aria-hidden on everything else, and a z-index arms race. Or you can use the gate the browser already built.

The fix: a real gate

HTML has a dialog element. Put a form inside it with method="dialog", give each button a value, and open it with showModal().

LeaveDialog.html
<button id="openBtn">Leave the dojo</button>
<dialog id="leave" aria-labelledby="leave-title">
<form method="dialog">
<h2 id="leave-title">Leave the dojo?</h2>
<button value="stay" autofocus>Stay</button>
<button value="leave">Leave</button>
</form>
</dialog>

leave.js
openBtn.addEventListener('click', () => {
    leave.returnValue = '';
    leave.showModal();
});
leave.addEventListener('close', () => {
    if (leave.returnValue === 'leave') goHome();
});

leave.css
dialog::backdrop { background: rgb(28 18 9 / 0.6); }

When a button submits a method="dialog" form, the dialog closes and its returnValue becomes that button's value. Nothing is sent to a server. Pressing Escape also closes a modal dialog, but it doesn't set returnValue, so the old value would stick around if you reuse the dialog. That is why the opener resets it to an empty string first.

KaiNo z-index, no key listener, no focus trap. Just HTML!

What showModal() does for you

Jobdiv modal<dialog> + showModal()
Stays on topz-index, if no stacking context gets in the wayTop layer, above every z-index, with a stylable ::backdrop
Page behindStill clickable, focusable and read aloudInert: no clicks, no focus, hidden from assistive tech
Focus on openStays on the openerMoves to the first focusable element, or the one with autofocus
Escape keyNothingFires cancel, then closes the dialog
Focus on closeWherever it wasBack to the element that had focus before it opened
Announced asA generic group of textrole="dialog" with aria-modal="true"

One method call, six jobs. Because the page behind is inert, Tab can't reach it: focus moves through the dialog's controls and, past the last one, out to the browser's own toolbar, never onto the page underneath.

Gates that still leak

  • show() or the open attribute makes a non-modal dialog. No inert page, no backdrop, and Escape doesn't close it. For a modal, always call showModal().
  • Clicking the backdrop doesn't close it by default. If you want that, close it when the click lands on the dialog element itself (see below). Newer browsers also support closedby="any", but check support before relying on it.
  • Don't put tabindex on the dialog. The dialog itself isn't interactive. Use autofocus on the control that should get focus first.
  • Give it a name. Point aria-labelledby at the heading so screen readers announce what the dialog is about.
  • Keep a visible close button. Not everyone has an Escape key, and touch users need something to tap.

backdrop-close.js
// The dialog has padding: 0 and its content sits in an inner wrapper,
// so a click whose target is the dialog itself landed on the backdrop.
leave.addEventListener('click', (e) => {
    if (e.target === leave) leave.close();
});

KaiSo even a real gate leaks if I open it with show().

Inspect the gate in DevTools

1. Elements panel. Open the dialog, then select it in Chrome DevTools. An open modal dialog shows a top-layer badge, and its ::backdrop appears as a pseudo-element right inside it.

2. Accessibility pane. With the dialog selected, open the Accessibility pane. The computed role should be dialog, and the name should come from your heading via aria-labelledby.

3. Console. Check where focus is before and after.

DevTools console
// after showModal() document.activeElement //
<button value="stay" autofocus>
// after closing with Esc or a button
document.activeElement // <button id="openBtn">

If focus is still on the opener after the dialog opens, or Tab reaches anything behind it, the Hollow Wall is still standing.

VishwaFocus goes in, Escape works, focus comes home. That is a gate.

 

Before you ship a modal

  • Is it a <dialog>, opened with showModal()?
  • Does focus move inside when it opens, and back to the opener when it closes?
  • Can Tab ever reach the page behind it?
  • Does Escape close it, and is there a visible close button too?
  • Is it named with aria-labelledby, and does returnValue get reset before each open?
Acharya's Rule #12

A modal is a <dialog> opened with showModal(), never a div in disguise.

Next episode: Raiden's Throttle. Why your search box fires a request on every keystroke, and when to debounce or throttle it.

Think you can bring down the Hollow Wall yourself? Play Vishwa's Dialog Gate on domlok.in and earn seal 12.

Watch the 36-second version on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.

Further reading

 

Frequently asked questions

Why can I tab out of my modal?+
A modal built from a div doesn’t move focus into itself or make the page behind it inert, so Tab simply moves on to the next focusable element in the page. A native dialog element opened with showModal() makes everything outside it inert, so Tab can’t reach the page behind.
What is the difference between show() and showModal()?+
showModal() opens a modal dialog in the top layer with a ::backdrop, makes the rest of the page inert and lets the Escape key close it. show() opens a non-modal dialog: the page behind stays interactive and Escape doesn’t close it by default.
How do I close a dialog with the Escape key?+
A dialog opened with showModal() closes on Escape by default, firing a cancel event and then a close event. A dialog opened with show() or the open attribute doesn’t close on Escape unless you add that yourself.
How do I know which button closed a dialog?+
Put the buttons in a form with method=”dialog” and give each one a value. When a button submits the form, the dialog closes and its returnValue is set to that button’s value, which you can read in the close event. Escape doesn’t change returnValue, so reset it to an empty string before each open.
Does the HTML dialog element trap focus?+
When it is opened with showModal(), everything else in the document becomes inert, so focus stays on the dialog’s controls (and the browser’s own toolbar) instead of the page behind. When the dialog closes, browsers move focus back to the element that had it before the dialog opened.