Identifying and Avoiding Keyboard Traps
AI generated
A11Y
WCAG
Accessibility · Keyboard Navigation · WCAG 2.1.2 · Frontend
Identifying and Avoiding Keyboard Traps
Finding, testing, and fixing focus traps properly

A keyboard trap happens when keyboard focus enters a modal, dropdown, or third-party widget and can't find its way back out with Tab or Shift Tab. For keyboard users and screen reader users, that means a complete blocking point. This article covers common causes, a systematic test procedure, and a correct focus trap implementation that can always be exited reliably with the Escape key.

13 min. read WCAG 2.1.2 · Focus Management · Focus Trap Modal Dialogs · Custom Widgets · axe-core

1. What is a keyboard trap?

A keyboard trap occurs when keyboard focus moves into a component but can't leave it again using standard keyboard means such as Tab, Shift Tab, arrow keys, or Escape. The success criterion WCAG 2.1.2 "No Keyboard Trap" is part of conformance level A, making it one of the most fundamental requirements in the entire WCAG. Without this property, a page can become completely unusable for keyboard users in the worst case, no matter how well every other criterion is met.

In practice, a keyboard trap feels like a frozen browser, even though the page is technically running fine. It mainly affects users who navigate exclusively with the keyboard, for instance due to motor impairments, and screen reader users, whose entire interaction runs through keyboard commands. The only way out is often a full page reload, which discards any form data already entered. Since the European Accessibility Act and Germany's Barrierefreiheitsstärkungsgesetz came into effect, this is no longer a theoretical risk for many online stores, but a concrete legal audit point.

2. Cause 1: Broken modal implementations

Modals are the most common source of keyboard traps, because developers deliberately want to keep focus inside them and then forget the exit logic. A typical pattern: a keydown listener intercepts every Tab press and forces focus back to a fixed element, regardless of where focus currently sits. Without a working Escape handler and without real cycle logic between the first and last focusable element, the user ends up permanently stuck in that one field.

A second common problem is a modal that doesn't deliberately set focus when it opens, so the browser keeps tabbing through the background content while the visible dialog sits on top. That isn't a classic trap in the strict sense, but it creates the same confusion, because visible focus and actual focus diverge. Both failure modes almost always arise because focus logic was bolted onto existing code afterward, instead of being designed in from the start.


<!-- BROKEN: modal markup with no role, no aria-modal, no exit logic -->
<div id="promo-modal" class="modal-overlay" style="display: none;">
  <div class="modal-content">
    <p class="modal-title">Claim the offer</p>
    <input type="email" placeholder="Email address">
    <button id="modal-submit">Sign up</button>
    <button id="modal-close">Close</button>
  </div>
</div>

<script>
// Every Tab press is redirected back to the first input field.
// There is no cycle logic and no Escape handler at all,
// so the "Close" button can never be reached via keyboard.
document.getElementById('promo-modal').addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  event.preventDefault();
  document.getElementById('modal-submit').focus();
});
</script>

3. Cause 2: Third-party widgets and iframes

Embedded third-party components such as payment widgets, map views, video players, or chat bots are a particularly tricky source of keyboard traps, because your own code has no access to the widget's internal state. If the component runs inside an iframe, your focus control ends at the document boundary. If the script inside the iframe misbehaves and intercepts Tab events without ever releasing them, there's no way to prevent or fix that from the outside without contacting the vendor.

Another pattern involves embedded widgets without an iframe, such as third-party React or Vue components rendered directly into the DOM. Those components frequently ship their own keyboard handlers, which collide with the surrounding focus trap of the modal they're embedded in. Two competing trap implementations in nested components are a classic recipe for a keyboard trap that neither side alone causes, but that only emerges from the interaction between the two.

4. Cause 3: Custom dropdowns and comboboxes

Hand-rolled dropdowns and comboboxes that recreate native <select> semantics using div and li structures with ARIA roles are a frequent trap candidate, because developers mix up Tab and the arrow keys. The WAI-ARIA Authoring Practices specify that arrow keys navigate within an open listbox or combobox, while Tab leaves the entire widget and moves to the next focusable element. If Tab is treated like just another arrow key, the user can never leave the component via keyboard again.

This gets especially critical with multi-level dropdowns that have subcategories, as often found in Magento category navigation. Each nesting level brings its own keydown handler, and if just one level fails to pass Tab through correctly, the entire tree is affected. The example below shows the typical mistake: a handler that treats Tab like ArrowDown, blocking every possible way to leave the list.


// BROKEN: this keydown handler traps focus inside the dropdown
// because Tab is intercepted like an arrow key
dropdownButton.addEventListener('keydown', (event) => {
  const options = dropdownList.querySelectorAll('[role="option"]');
  const currentIndex = [...options].findIndex((o) => o === document.activeElement);

  switch (event.key) {
    case 'ArrowDown':
    case 'Tab': // Bug: Tab should move focus OUT, not cycle inside
      event.preventDefault();
      options[(currentIndex + 1) % options.length].focus();
      break;
    case 'ArrowUp':
      event.preventDefault();
      options[(currentIndex - 1 + options.length) % options.length].focus();
      break;
    // No 'Escape' case at all: users cannot close and leave the widget
  }
});

5. Systematically testing for keyboard traps

The most reliable test is also the simplest: put the mouse and trackpad away entirely and operate every interactive page using only Tab, Shift Tab, Enter, Space, arrow keys, and Escape. Deliberately open every modal, every dropdown, and every embedded widget, then press Tab repeatedly to check whether focus eventually lands outside the component or keeps circling the same element forever. In parallel, test separately for each open component whether Escape reliably closes it.

A structured test protocol typically covers three cases per component: open and tab all the way to the last focusable element, open and immediately press Escape, and open followed by Shift Tab from the first element. If any of these three cases breaks the expected behavior, you have a trap, or at least a broken focus boundary. It's also important to run the same test in at least two browsers, since default tab order for certain form elements can differ between Chromium and Firefox.

6. Building a correct focus trap implementation

A correct focus trap implementation needs three building blocks: a list of every currently visible, focusable element inside the container, cycle logic that jumps back to the first element when Tab is pressed on the last one and vice versa for Shift Tab on the first, and a global Escape handler that always works regardless of the current focus target. The list of focusable elements must not be computed once when the dialog opens; it needs to be recalculated on every Tab press, because a modal's content can change at any time through user interaction.

It's also important to exclude hidden or invisible elements from this list, such as elements with display: none or those hidden via the hidden attribute. The class below implements exactly this pattern as a reusable component that can be instantiated for any modal, independent of the surrounding framework.


// FocusTrap: keeps keyboard focus inside a container,
// but Escape always exits, no matter where focus currently is
class FocusTrap {
  constructor(container, { onEscape } = {}) {
    this.container = container;
    this.onEscape = onEscape;
    this.previouslyFocused = document.activeElement;
    this.handleKeydown = this.handleKeydown.bind(this);
  }

  getFocusableElements() {
    const selector = [
      'a[href]', 'button:not([disabled])', 'input:not([disabled])',
      'select:not([disabled])', 'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(',');
    return Array.from(this.container.querySelectorAll(selector))
      .filter((el) => el.offsetParent !== null);
  }

  activate() {
    document.addEventListener('keydown', this.handleKeydown, true);
    const focusable = this.getFocusableElements();
    (focusable[0] || this.container).focus();
  }

  deactivate() {
    document.removeEventListener('keydown', this.handleKeydown, true);
  }

  handleKeydown(event) {
    // Escape always wins, regardless of the currently focused element
    if (event.key === 'Escape') {
      event.preventDefault();
      this.deactivate();
      this.onEscape?.(this.previouslyFocused);
      return;
    }

    if (event.key !== 'Tab') return;

    const focusable = this.getFocusableElements();
    if (focusable.length === 0) {
      event.preventDefault();
      return;
    }

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }
}

7. Getting the Escape key and focus return right

Escape must work in every state of the dialog, even when focus currently sits inside an input field where Escape normally does something else, such as clearing a search box. The listener should therefore be registered with capture: true, so it fires before more deeply nested components can intercept and stop the event. Just as important as closing itself is where focus goes afterward: if it falls back to <body>, the user loses all context and has to re-orient on the entire page from scratch.

The correct solution remembers the element that had focus right before the dialog opened, usually the button that triggered the modal, and returns focus exactly there when it closes. Visually, this jump back should be just as clearly visible as the jump in when the dialog opened, so that sighted users and screen reader users alike can tell where they currently are. A visible focus ring is mandatory here, not optional.


/* Visually hidden sentinel elements at the start and end of the
   modal, used as an additional signal that focus is about to
   leave the trapped area via Tab or Shift+Tab */
.focus-trap-sentinel {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Make the current focus target unmistakable inside the trap */
.modal-content :focus-visible {
  outline: 3px solid #71717a;
  outline-offset: 2px;
}

/* Never rely on outline: none without a visible replacement */
.modal-content button:focus {
  outline: 3px solid #71717a;
}

8. Automated testing with axe-core and Playwright

Automated tools like axe-core reliably catch many accessibility problems, but only detect a keyboard trap indirectly, because the failure only becomes visible through real, multi-step keyboard interaction, not through a single static DOM analysis. The most reliable way to catch keyboard traps automatically is therefore an end-to-end test that simulates real Tab and Escape key presses with Playwright and checks after every step whether document.activeElement is still inside the expected container.

Such a test ideally combines both layers: axe-core checks structural prerequisites like correct ARIA roles and a named dialog, while the actual keyboard flow with Playwright secures the dynamic behavior. Integrated into a CI pipeline, this test reliably prevents a future refactor of the modal code from silently introducing a new keyboard trap, one that would otherwise only surface through manual user complaints.


import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('modal has no keyboard trap and returns focus on Escape', async ({ page }) => {
  await page.goto('/newsletter');
  const trigger = page.getByRole('button', { name: 'Show offer' });
  await trigger.focus();
  await trigger.press('Enter');

  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();

  // Tab through the entire dialog twice; focus must never leave it
  for (let i = 0; i < 12; i++) {
    await page.keyboard.press('Tab');
    const activeInDialog = await dialog.evaluate(
      (el, active) => el.contains(active),
      await page.evaluateHandle(() => document.activeElement)
    );
    expect(activeInDialog).toBe(true);
  }

  // Escape must always close the dialog and return focus
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();

  // axe-core catches structural issues, but not the trap itself
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

9. Keyboard traps compared side by side

The table below summarizes the most common trap scenarios and contrasts the error-prone pattern with the correct one. It works well as a quick checklist during code review of modal, dropdown, and widget components.

Scenario Error-prone pattern Correct pattern Effect
Opening a modal No aria-modal, no initial focus role="dialog" aria-modal="true" + focus on the first element Screen reader announces the dialog correctly
Tab at the end of the dialog Focus disappears into background content Cycle back to the first element Focus stays visible inside the dialog
Escape key No global handler registered Listener with capture: true on document Users can exit at any time
Closing the dialog Focus falls onto body or disappears Focus returns to the triggering element Context is preserved for the user
Custom dropdown Tab is intercepted like an arrow key Only arrow keys navigate, Tab leaves the widget Expected keyboard behavior is preserved

In practice, several of these mistakes tend to show up together, because they share the same root cause: focus logic gets bolted onto existing code afterward, instead of being designed from the start as a complete system of trapping, cycling, escaping, and returning. Anyone who runs through this table as a checklist for every new interactive widget avoids the vast majority of keyboard traps before they ever reach production.

Mironsoft

Accessibility, focus management, and accessibility testing for Magento and Hyvä stores

Ready to track down and fix keyboard traps?

We systematically test your Magento or Hyvä store's modals, dropdowns, and third-party widgets with keyboard and screen reader, identify focus traps, and implement robust focus trap solutions with a proper Escape exit.

Keyboard audit

Manual keyboard testing of every modal, dropdown, and embedded widget

Focus trap fixes

Retrofitting robust FocusTrap components with an Escape exit into Hyvä templates

CI integration

Playwright and axe-core tests against regressions in the deployment pipeline

10. Summary

A keyboard trap almost always arises from missing or hastily bolted-on focus logic in modals, third-party widgets, or custom dropdowns that capture keyboard focus without providing a reliable exit via Escape or Tab. Systematic keyboard-only testing surfaces these bugs in just a few minutes per component, far faster than most other accessibility issues. A correct focus trap implementation needs three fixed building blocks: a dynamically computed list of focusable elements, cycle logic between the first and last element, and a global Escape handler that reliably returns focus to the triggering element.

Automated tools like axe-core check the structural prerequisites, but they don't replace a real keyboard test, because a keyboard trap only becomes visible through multi-step interaction. Building a Playwright test suite against exactly these three cases, opening and tabbing to the end, immediate Escape, and Shift Tab from the first element, permanently prevents future refactors from quietly shipping new traps to production.

Identifying and Avoiding Keyboard Traps, the Essentials at a Glance

Definition & WCAG

WCAG 2.1.2 "No Keyboard Trap", Level A. Focus enters but can't leave again via Tab, Escape, or arrow keys.

Most common causes

Broken modal implementations, third-party widgets in iframes, and custom dropdowns that treat Tab like an arrow key.

Test method

Put the mouse away, work through every component with Tab, Shift Tab, and Escape. Check three cases per component.

Correct pattern

Dynamic focus list, cycling between first and last element, global Escape handler with focus return.

11. FAQ: Identifying and Avoiding Keyboard Traps

1What exactly is a keyboard trap?
Keyboard focus moves into a component and can't leave it via Tab, Shift Tab, arrow keys, or Escape. Affected users are blocked until they reload the page.
2Which WCAG success criterion covers keyboard traps?
WCAG 2.1.2, No Keyboard Trap, conformance level A. One of the most fundamental requirements in all of WCAG, since without it a page can become unusable for keyboard users.
3How do I manually test for a keyboard trap?
Put the mouse away, operate every component with Tab, Shift Tab, Enter, Space, arrow keys, and Escape. Press Tab repeatedly and check whether focus can escape again.
4Why do modals cause keyboard traps so often?
Missing cycle logic between the first and last element plus a missing Escape handler keep the user permanently stuck in a single field.
5What makes third-party widgets especially risky?
If the widget runs inside an iframe, your own focus control ends at the document boundary. Broken Tab handling inside the iframe is hard to fix from the outside.
6How do I build a correct focus trap implementation?
A dynamically computed focus list, cycling between the first and last element, and a global Escape handler that works regardless of current focus.
7Why must Escape always work?
Users need a guaranteed way out. A listener with capture: true on document ensures Escape fires before nested components can stop the event.
8Where should focus go after closing?
Back to the triggering element, usually the button that opened the modal. Falling back to body makes the user lose context.
9How do I automate tests for keyboard traps?
With Playwright, simulating real Tab and Escape key presses and checking document.activeElement after every step. This catches regressions in the CI pipeline.
10Can axe-core automatically detect keyboard traps?
Only indirectly via structural prerequisites. The actual trap only becomes visible through multi-step interaction, so real keyboard tests with Playwright are also needed.