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.
Table of Contents
- 1. What is a keyboard trap?
- 2. Cause 1: Broken modal implementations
- 3. Cause 2: Third-party widgets and iframes
- 4. Cause 3: Custom dropdowns and comboboxes
- 5. Systematically testing for keyboard traps
- 6. Building a correct focus trap implementation
- 7. Getting the Escape key and focus return right
- 8. Automated testing with axe-core and Playwright
- 9. Keyboard traps compared side by side
- 10. Summary
- 11. FAQ
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.