Search topics
Pop-ups that can't be closed with a keyboard
A sign-up pop-up keeps Tab inside it, ignores Escape and draws its × as a span, so keyboard users can't close it without signing up.
<span class="popup-close">×</span> The problem
Keyboard users can't close the sign-up pop-up, so they can't reach the page behind it without signing up.
Juniper Ceramics, a pottery shop, shows a pop-up five seconds after a page loads: "Get 10% off your first order", an email field, a Sign up button, and a × in the top corner. A mouse user clicks the × and carries on shopping. A keyboard user finds focus already in the email field. Tab moves to Sign up, and the next Tab goes back to the field; Shift + Tab goes round the other way. The × is never reached. Escape does nothing. The only key that takes focus out of the pop-up is Enter, sending the form with an email address typed in, so a visitor who doesn't want to give one can't get to the page behind it.
Success criterion 2.1.2 No Keyboard Trap asks that when focus can move into a part of the page with the keyboard, it can move away again with the keyboard alone, and that the page says how if it takes more than the usual keys. For a dialog, the usual key is Escape: the criterion's Understanding page names it a commonly used standard exit method, and the ARIA Authoring Practices give it to every modal dialog.
The pop-up's script keeps Tab inside it, which is right for a modal: it stops focus wandering through the page behind the overlay. A modal that holds focus has to come with a way out, and this one has none for the keyboard. The × is a span with a click listener, and a span never takes focus, so Tab goes round the field and Sign up without stopping on it. No handler listens for Escape. Signing up is left as the only way out.
2.1.2 is one of the four criteria that apply to everything on a page, even content the site does not rely on to conform (the non-interference requirement in WCAG's conformance section). A pop-up added by a marketing script is part of the page, wherever its code comes from.
A mouse user closes the pop-up with one click, and that is how it is usually checked. The × is on screen the whole time, so a sighted keyboard user can see the way out and still can't reach it.
2 of 10 groups affected
- No vision (affected)
- Low vision (not affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Limited manipulation Keyboard users, and switch users whose switches send Tab and Enter, are held in the pop-up: the only way on is to sign up, and reloading the page brings the pop-up back. Voice control users have no name to say for the ×.
- Without vision Screen reader users hear the heading, the email field and Sign up. The × is not a button, so it is never announced as one, and nothing tells them the pop-up can be closed.
Tab going round a few controls is a trap almost anywhere else, but in an open modal it is what should happen, so a check that only tabs through the page has to let it pass. Whether the dialog has a way out shows only when Escape is pressed and the close control is looked at. Many pop-ups also open some seconds after the page loads, or when the pointer heads for the address bar, after a quick scan has moved on. They are often set up in a marketing tool's dashboard and never reviewed with the site's own code. AI assistants asked for a pop-up can write this pattern, because the focus loop makes it look carefully done.
Try it
Tab into the shop’s page, then close the pop-up that opens, with the keyboard alone.
Try Tab, Shift + Tab and Escape: does focus ever reach the ×?
Apply a fix and try again, and see where focus is once the pop-up has closed.
The fix
Make the × a named button, and close the pop-up on Escape, with showModal() or with a key handler.
- 1A pop-up that holds focus closes on Escape.
- 2Its close control is a button with a name, and Tab reaches it.
- 3Closing it puts focus back where the person was.
<div class="popup-overlay" hidden> <div class="popup" role="dialog" aria-modal="true" aria-labelledby="popup-title"> <span class="popup-close">×</span> <h2 id="popup-title">Get 10% off your first order</h2> <p>Sign up for news of new pieces and sales.</p> <form class="popup-form"> <label for="popup-email">Email address</label> <input id="popup-email" name="email" type="email" autocomplete="email" required> <button>Sign up</button> </form> </div></div>A native dialog
<dialog class="popup" aria-labelledby="popup-title"> <button type="button" class="popup-close" aria-label="Close">×</button> <h2 id="popup-title">Get 10% off your first order</h2> <p>Sign up for news of new pieces and sales.</p> <form class="popup-form"> <label for="popup-email">Email address</label> <input id="popup-email" name="email" type="email" autocomplete="email" required> <button>Sign up</button> </form></dialog>Why this fix: Use native HTML first
The loop sends Tab from Sign up back to the field, and the span is not on its list or anywhere in the Tab order, so the × only works for a pointer. Nothing handles Escape, and the form's submit is the only other code that closes the pop-up.
showModal() makes the page behind the dialog inert, so Tab never reaches it. On its own, though, the browser sends Tab from the dialog's last control out to its own controls, such as the address bar, so the short loop sends it back to the × instead, as the ARIA Authoring Practices' modal dialog pattern does, and focus stays in the pop-up. Escape closes it. When it closes, focus goes back to the element that had it when the pop-up opened. The × is now a button, so Tab reaches it, Enter and Space press it, and its aria-label gives it the name "Close", which a screen reader reads where it would read the symbol. The overlay div goes, because the browser draws the dialog's backdrop with the ::backdrop pseudo-element.
Both give the keyboard two ways out, Escape and a × that Tab reaches, and both put focus back afterwards. Use the native dialog: the browser closes it on Escape and makes the page behind it inert, so there is less code, and no later change to the script can take Escape away. Keep the custom pop-up when its markup comes from a component or tool you can't swap for a dialog element; then check Escape and the × again after every change to it.
Sign-up pop-ups, exit offers and consent dialogs are usually set up in a marketing tool or a consent platform, and their code is the vendor's. What you can do.
- Test each pop-up the tool shows before you switch it on, with the steps under "Verify the fix", on a test page if the tool allows it.
- Check the tool's settings. Some offer an accessible template or a "close on Escape" option; choose one with a visible close button, and turn off pop-ups that open again on every page.
- Ask the vendor whether its pop-ups close on Escape, whether the close control is a button with a name, and where focus goes when they close.
- Report what fails to the vendor, with the steps to reproduce it. Until it is fixed, list it as a known issue in your accessibility statement, and turn the pop-up off if it can't be closed from the keyboard: 2.1.2 applies to it however it got onto the page.
Vendor scripts change without a deploy on your side, so repeat the checks after each vendor update and each change to the pop-up's settings.
Consent dialogs. A cookie consent dialog that blocks the page follows the same rules. Every choice (Accept all, Reject all, Choose cookies) is a button that Tab reaches, and Escape closes it, saving nothing and asking again on the next visit. Where the banner sits in the page and whether it covers focus is the subject of cookie banners that hide keyboard focus and come last.
Don't cancel Escape. A native dialog fires a cancel event before Escape closes it. Calling preventDefault() on it to force a choice takes away the usual key for leaving it. If an answer really is required, give the dialog a button for every answer, each one reachable with Tab, rather than blocking Escape.
Keep the × in view. A close button that appears only when the pointer is over the pop-up is hidden until then, and when it is hidden with visibility: hidden or display: none, it can't take focus, so Tab never reaches it. Keep it visible whenever the pop-up is. A "No thanks" button beside Sign up is a second way out that everyone sees, and it says plainly what the × only implies.
Clicking the backdrop. Closing the pop-up when the dimmed page around it is clicked is a convenience for the pointer. It is never the way out for the keyboard, so it doesn't replace the button or Escape.
Tab after the last control. Without the loop, a native dialog sends Tab from Sign up to the browser's own controls, such as the address bar, and the next Tab back to the dialog's first control. WCAG's technique H102 describes this, and it is not a trap, but focus leaves the pop-up and the page. The loop keeps it in the pop-up, as the ARIA Authoring Practices' modal dialog pattern does. Keep the loop's list in step with the dialog: a control added to it that the list doesn't match is skipped when Tab wraps.
Don't just drop the loop. Removing the script that keeps Tab inside, without making the page behind inert, doesn't fix the pop-up: Tab then walks through the page under the overlay, where focus is hard to see, and the × is still out of reach. Focus inside a modal is the subject of modals that leave keyboard focus behind them.
Where focus goes after closing. A pop-up that opens by itself took focus from wherever the visitor was, perhaps halfway down a form. Put it back there, as showModal() does and the custom fix does by hand, so they carry on from where they were.
A chat panel. A support chat's close button and launcher fail in their own ways, covered in chat buttons that Tab skips, and close buttons with no name.
Verify the fix
5 checks, no mouse.
Test with: Keyboard, Screen reader
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the pop-up, the tool that shows it, or its settings change.
How screen readers handle <dialog>
Limits
This is a starting pattern for one pop-up, not a guarantee for every overlay. The snippets leave out the part that remembers a visitor has closed the pop-up, so it doesn't open on every page, and what subscribe() does with the address. Where a closed pop-up should send focus when nothing had focus before it opened depends on the page; leaving it to the browser is a reasonable default, but where Tab carries on from then differs between browsers, so check it in the ones you support. Test the complete journey with the assistive technology your visitors use.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.