# Pop-ups that can't be closed with a keyboard

Source: https://easeweb.dev/learn/closing-pop-ups
Topics: Dialogs and overlays > Dialogs and consent banners can always be closed with a keyboard (WCAG 2.1.2)
The fix: Give the pop-up a keyboard way out. Either open it as a native dialog with showModal(), which closes on Escape, and make its × a named button, or keep the custom pop-up and its focus loop, make the × a named button, and close the pop-up on Escape.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html https://www.w3.org/WAI/WCAG22/Techniques/general/G21 https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog

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.

## What happens

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.

## Why it fails

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.

## Who is affected

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.

## What automation and AI miss

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.

## Before: a pop-up whose × Tab never reaches

```html
<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>
```

```js
const overlay = document.querySelector('.popup-overlay');
const popup = overlay.querySelector('.popup');
const email = document.getElementById('popup-email');

function openPopup() {
  overlay.hidden = false;
  email.focus();
}
function closePopup() {
  overlay.hidden = true;
}

// The pop-up opens five seconds after the page loads.
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// Keeps Tab inside the pop-up while it is open.
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
```

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.

## After: a native dialog

```html
<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>
```

```js
const popup = document.querySelector('.popup');
const email = document.getElementById('popup-email');

// showModal() keeps the page behind out of reach, closes the dialog on Escape and puts focus back on close.
function openPopup() {
  popup.showModal();
  email.focus();
}
function closePopup() {
  popup.close();
}

// The pop-up opens five seconds after the page loads.
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// Sends Tab from the last control back to the first, rather than out to the browser.
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
```

`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.

## After: the same pop-up, with a close button and Escape

```html
<div class="popup-overlay" hidden>
  <div class="popup" role="dialog" aria-modal="true" 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>
  </div>
</div>
```

```js
const overlay = document.querySelector('.popup-overlay');
const popup = overlay.querySelector('.popup');
const email = document.getElementById('popup-email');
let previous;

function openPopup() {
  previous = document.activeElement;
  overlay.hidden = false;
  email.focus();
}
function closePopup() {
  overlay.hidden = true;
  previous?.focus();
}

// The pop-up opens five seconds after the page loads.
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// Keeps Tab inside the pop-up while it is open.
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
// Escape closes the pop-up. An input method uses Escape to cancel a conversion; leave that key to it.
popup.addEventListener('keydown', (event) => {
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closePopup();
});
```

The × becomes a `button`, so the loop's `querySelectorAll('button, input')` now finds it first, and Tab goes round the ×, the field and Sign up. A second listener closes the pop-up on Escape, and closing puts focus back where it was before the pop-up opened. Nothing else changes: the overlay, the loop and the rest of the markup stay as they were.

## Which option to use

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.

## When the pop-up comes from a third party

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](/learn/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.

## Implementation decisions

**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](/learn/cookie-banners).

**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](/learn/modal-focus-management).

**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](/learn/chat-launchers).

## Verify the fix

1. Put the mouse aside, load the page and wait for the pop-up: focus moves into it.
2. Press Tab and Shift + Tab until you are back where you started, twice: every control in it is reached, the × included, and focus never leaves the pop-up, for the page behind or for the browser's own controls.
3. Tab to the × and press Enter: the pop-up closes, and focus is where it was before the pop-up opened.
4. Load the page again, wait for the pop-up and press Escape: it closes the same way.
5. With a screen reader running, load the page again: the pop-up's heading is read as it opens, and the × is announced as "Close, button".

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.

## 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.

## What screen readers do (tested by hand by EaseWeb)

`<dialog>`: A dialog opened with showModal(): focus moves into it.

| Screen reader | Browser and system | Open it |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
