A modal has four jobs beyond looking right. Move focus into itself when it opens, hold focus inside while it is open, close when somebody presses Escape, and hand focus back to whatever opened it. Build it with the HTML dialog element and open it with showModal(), and the browser does all four. What is left for you is deciding where focus lands inside, and there is one attribute for that.
That is a much shorter list than most teams expect, and modals are still the component we find broken most often in our own audits. The reason is that all of the work is invisible. A modal that fails still opens, still looks correct, and still closes when a mouse user clicks the X. For everybody else it is a trap, in the literal sense the standard means by the word.
The Four Jobs, and Who Does Each One
| The job | What happens when it is missing | With the native element |
|---|---|---|
| Move focus in when it opens | A keyboard user is still somewhere on the page behind the dialog, tabbing through content they can no longer see | The browser does it |
| Hold focus inside while it is open | Tab runs off the end of the dialog into the page underneath, which is still there and still tabbable | The browser does it |
| Close on Escape | The only way out is a mouse click on a close button somebody has to be able to see | The browser does it |
| Return focus on close | The user is dropped at the top of the document with no idea where they were before | The browser does it, so long as the control that opened the dialog is still on the page |
That last condition is the one to watch. If the trigger is gone by the time the dialog closes, because the dialog deleted the row its own button lived in, the browser has nowhere to send focus and the choice comes back to you. Put it somewhere that makes sense for what just happened, near where the removed thing used to be.
The Element That Does Most of It For You
showModal() has worked across the major browsers since March 2022, so this is settled rather than emerging. Calling it gives you focus containment, the Escape key, an inert background, focus moved in on open, and focus returned on close, for markup you were writing anyway. Our fix guide covers the native dialog element with worked markup, and it is worth knowing which criterion it discharges when you write it up, because that is 2.4.3 Focus Order rather than the keyboard-trap rule people reach for.
The one decision left to you is where focus starts, and autofocus is how you make it. Put the attribute on the element the person is expected to use first. Put it on the dialog element itself when there is no such element. What you should not do is call .focus() on a timer, and you should not put tabindex on the dialog either, because the dialog is not an interactive control and is not meant to take focus in its own right.
Two more things come free once you are using the element. A form inside the dialog carrying method="dialog" closes it on submit without any JavaScript, keeping the state of its controls rather than sending them anywhere, and the browser still returns focus afterwards. And where two dialogs are stacked, Escape closes only the one shown last, which is the behavior people expect and a genuinely awkward thing to write by hand.
If you are building it by hand
You owe role="dialog", an accessible name from aria-labelledby pointing at the heading, focus moved in and restored, an Escape handler, and the content outside made inert. The attribute for that last one is inert, which takes the rest of the page out of the tab order, out of the accessibility tree, and out of pointer events at once. The ARIA Authoring Practices dialog pattern has the full specification, and it strongly recommends one more thing we would not skip. Put a visible close control in the dialog's own tab sequence.
aria-modal Is a Claim, Not a Setting
aria-modal="true" tells assistive technology that nothing outside this element is available, and a screen reader acts on that by hiding the rest of the page. Set it only when two things are both true. Your code actually stops every user interacting with anything outside the dialog, and your styling actually obscures what is behind it. Set it on something that is only half modal and the people using assistive technology get a page they cannot perceive while everybody else can still click straight through to it. That is a worse outcome than leaving the attribute off.
Older implementations do the same job with aria-hidden instead, and there are two conditions on that route. Every element holding a piece of the inert layer needs aria-hidden="true" on it, and the dialog itself must not sit inside anything that is hidden that way, or you have hidden the dialog along with the page.
Where to Put Focus Inside
Not always the first focusable element. Think about what the person came for, because the thing under their fingers when the dialog opens is the thing they are most likely to press.
| Dialog | Focus goes to | Why |
|---|---|---|
| A form in a modal | The first field | That is the task |
| A confirmation with a destructive action | The least destructive control, usually Cancel | A mistaken Enter should not delete anything, especially where undoing it is hard or impossible |
| A long message with structure in it | A static element at the top carrying tabindex="-1" | So the whole thing gets read from the beginning rather than from halfway down |
| A dialog that informs and continues | The most-used control, such as OK or Continue | That is what people opened it to press |
| An alert | The dialog itself | The message is the point, not an action |
Almost never focus the close button. It is the one action nobody opened the dialog to take. The exception is the dialog whose only action is dismissal, where the close button genuinely is the most-used control and reading the message is the whole point.
When the Dialog Is an Alert
A dialog that interrupts somebody to say something important and get an answer is a different role. role="alertdialog" covers the confirmation prompt and the error that has to be acknowledged, and it exists so that assistive technology and browsers can treat those differently from an ordinary dialog, including playing the system alert sound.
It also has a stricter naming rule than the plain dialog. A dialog wants a name. An alert dialog wants a name and a description, so aria-describedby has to point at the element holding the message. On a plain dialog the same attribute is optional and there is a case for leaving it off. A description is announced as one unbroken string, so pointing it at content built from lists, tables, or several paragraphs turns something readable into a run-on sentence.
The Failures We Find
- No Escape handler. The one we see most. Worth being precise about what it costs, because a dialog with no Escape and no reachable close control is a keyboard trap outright, while a dialog with no Escape and a tabbable close button is not a trap. It is just unpleasant, and it is still worth fixing.
- Focus never moves in. The dialog appears and the keyboard user is still standing behind it.
- Focus escapes. Tab runs off the end of the dialog and into the page below, which is still live and still tabbable.
- Focus is lost on close. Returned to the top of the document rather than to the trigger.
- The background is not inert. A screen reader user arrows straight past the dialog into content that is visually hidden behind an overlay and cannot be seen at all.
- No accessible name. Announced as dialog and nothing after it, which is a 4.1.2 finding.
- Close only by clicking outside. Fine as an extra route out, useless as the only one.
- Positive
tabindexvalues inside the dialog. Anything above zero is strongly discouraged, and it is a live cause of the focus-order problems in this list rather than a fix for them.
Not Everything That Floats Is a Dialog
The HTML standard is direct about this. Using the dialog element to build a context menu, a tooltip, or a popup listbox is not conforming, because none of those is a dialog box. Reaching for dialog because it gives you the top layer for free is the common way teams end up here, and a tooltip has its own rules that a dialog does not satisfy.
The standard also advises against scrollable dialogs, on the grounds that people do not expect one. Sometimes you cannot avoid it, because somebody has their text zoom turned up and the content simply does not fit. What causes it needlessly is putting large blocks of text straight into the dialog element. Put the long content in a scrolling div inside the dialog instead, and put autofocus on that div, which solves the reading problem and the overflow problem in the same move.
How to Test One in a Minute
- Open it with the keyboard, from the control that is supposed to open it. Where did focus go?
- Tab all the way round. Does focus stay inside?
- Try to reach the page behind it. You should not be able to, by Tab or by screen reader cursor.
- Press Escape. Did it close?
- Where is focus now? It should be back on the control you opened it with.
- Reopen it with a screen reader running. Is it announced, and does it have a name?
One honest limit
A scanner can check that role and aria-modal are present. It cannot press Escape, and it cannot tell you focus went to the wrong place. Every one of the four jobs is a behavior rather than an attribute, so all four need a person testing them. Worth testing across browsers as well. The specification is clear about what the native element does, and the finer details of dialog focus handling have been reported to differ between browsers, including how autofocus on the dialog element itself behaves.