How Do You Build an Accessible Modal With the Dialog Element?
How do you build an accessible modal with the dialog element?
Put your content in a dialog element, open it with showModal, put autofocus on the close button, and style the backdrop with the backdrop pseudo element. The browser handles the top layer, inerting the page behind, focus containment and the Escape key. That is most of the work done for you.
We still find hand rolled modal code on almost every site we inherit. Usually it is a div with a fixed position, a focus trap borrowed from a library, a keydown listener for Escape, and a scroll lock that fights the iOS keyboard. All four of those are now browser behaviour.
This is the walkthrough we give client dev teams, including the parts that still catch people out.
What does showModal actually do?
It opens the dialog as a modal and blocks interaction with the rest of the page. MDN describes it as adding the dialog to the top layer, with everything else becoming inert. The contrast is with the show method, which opens a non modal dialog and leaves the rest of the page usable.
The top layer part is what removes an entire category of CSS bugs. A modal in the top layer renders above all other content regardless of z-index, stacking contexts or a parent with overflow hidden. If you have ever spent an afternoon on a modal trapped behind a sticky header, that afternoon does not happen anymore.
Inerting is the accessibility half. When showModal opens a dialog, the browser renders everything outside it as inert, so screen reader users and keyboard users cannot wander into the page behind the overlay. That is the behaviour most custom modals implement badly or not at all.
How do you handle focus correctly?
With the autofocus attribute, on purpose. MDN states that when showModal opens a dialog, focus moves to the first nested focusable element, and recommends using autofocus to set initial focus explicitly. Its guidance is to put autofocus on the close button, or on the dialog itself when no immediate interaction is expected.
The default is the trap here. First focusable element is often a decorative link or the first field in a long form, neither of which is where a person wants to land. Being explicit costs one attribute and removes the guesswork.
One firm warning from the same documentation: do not add tabindex to the dialog element itself, because it is not interactive and should not receive focus. We see this added defensively by people who assume they need it. They do not. Our piece on screen reader testing covers how to check the result rather than assume it.
How does the Escape key behave?
Modal dialogs opened with showModal close on Escape by default. Non modal dialogs do not, unless you configure it. When several modals are open, Escape closes only the topmost one, which is the behaviour a person expects and is fiddly to write by hand.
That default closing behaviour is why you can delete your keydown listener. It is also why you should test that deleting it does not break anything else, because a global Escape handler somewhere in your app may now be firing alongside the native one.
The close event fires when the dialog closes by any route, so that is the single place to put cleanup logic. One listener instead of three.
Can you control what closes the dialog?
Yes, with the closedby attribute, which MDN documents with three values. The value any allows Escape, a button, or a click outside. The value closerequest allows Escape or a button. The value none allows a button only. Dialogs opened with showModal default to closerequest, and other dialogs default to none.
This is the feature that replaces the light dismiss code everyone writes. Clicking the backdrop to close used to mean listening for clicks on the dialog element and comparing the event target against its bounds, which is awkward because the backdrop is technically part of the dialog. Now it is an attribute.
Check support before you rely on it, though. The dialog element itself has been Baseline widely available since March 2022, but closedby and the newer invoker command attributes arrived later than the element and are not in the same position. Treat them as progressive enhancement and make sure a close button always works. Our piece on reading Baseline for web features explains how to check this properly.
How do you style the backdrop?
With the backdrop pseudo element, which targets the layer the browser paints behind a modal dialog. You can set a background colour, a gradient, opacity, a blur filter. It behaves like an ordinary element for styling purposes, which is more than the old overlay div ever managed.
The practical note is that the backdrop is not a child of your dialog in the DOM, so you cannot animate it with the same transition as the dialog content without care. Plan for two transitions, not one, and give both the same duration so they feel like a single motion.
Respect motion preferences here as well. A modal that slams into place is the kind of thing that causes real discomfort for some people, and the media query to soften it is three lines.
What does the dialog element still not give you?
A few things. It does not decide what your dialog should say, which is the part that actually determines whether people complete the action. It does not stop the page behind from scrolling in every browser and every situation, so test that specifically. And it does not make a bad interaction good.
It also does not save you from the most common modal problem, which is using one at all. A modal interrupts. If the content is important enough to interrupt for, it is often important enough to be a page. We push back on at least one proposed modal in most projects.
MDN's own accessibility guidance carries the useful reminder that you should always provide a closing mechanism, and recommends an explicit close button. Relying on Escape alone fails anyone on a touch device, which is most of your traffic.
Is it the same thing as the popover API?
No, and mixing them up leads to the wrong choice. A dialog is for a task that needs an answer before you continue, which is why showModal makes the rest of the page inert. A popover is for something transient and non blocking, like a menu or a tooltip, where the page underneath stays usable.
The overlap is real because both use the top layer and both have light dismiss behaviour. The test we use is whether the rest of the page should still work. If yes, it is a popover. If no, it is a modal dialog. Our guide to the popover API goes through the other side of that choice.
Getting this wrong is not a cosmetic mistake. A menu built as a modal dialog makes the entire page inert for a keyboard user who just wanted to see the options.
How do you migrate an existing modal without breaking it?
One component at a time, starting with the simplest. Replace the wrapper with a dialog element, swap your open function for showModal, delete the focus trap, delete the Escape listener, delete the inert or aria-hidden juggling, and keep everything inside the dialog exactly as it was.
Then test three things specifically. Keyboard tab order stays inside the dialog. A screen reader announces the dialog and cannot reach the page behind. The close button works with a mouse, a keyboard and a touch screen. That covers the failures we actually see.
Expect the diff to be mostly deletions, which is the satisfying part. On the builds where we have done this, the modal component usually loses more lines than it gains. Less code is less to maintain and less to get wrong.
Does any of this matter for performance?
A little, and it compounds. Every focus trap library, scroll lock helper and overlay utility you delete is JavaScript that no longer parses and executes on first load. None of them is large alone. Together they are a meaningful slice of a marketing site's bundle.
On our own client builds, cutting this kind of utility code is part of how pages stay fast. The Axis Align site holds a 98 PageSpeed score and a 0.7 second load, and that comes from a long series of small decisions like this rather than one clever optimisation.
Native behaviour is also faster at runtime than the JavaScript equivalent, because the browser is doing it in C++ rather than in your event loop. That difference is invisible on a laptop and visible on a cheap phone.
Should this be your default now?
Yes. The dialog element has been Baseline widely available since March 2022, the accessibility behaviour is built in, and the code it replaces is the code most likely to be subtly wrong. There is no longer a good argument for hand building a modal on a marketing site.
Keep a close button, keep the content short, and keep asking whether the modal should exist at all. Those are the decisions that are still yours.
If you want a review of the modal and overlay patterns on your site, or help cutting the utility code they are built on, we are happy to walk through it. Find us at phoenix.studio.
Want a site that performs like this?
Tell us about your project. We will come back with a clear next step, no pressure.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Have a project like this?
Tell us where you want to go. We'll tell you how we'd get you there.