( Operable / WCAG 2.1.2 )

Focus is trapped inside a component

SeriousLevel AWCAG 2.1.2 — No Keyboard Trap

What is this issue?

Keyboard focus enters a container (a modal dialog, a dropdown menu, a custom widget) and no combination of Tab, Shift+Tab, or Escape moves focus back out of it. The keyboard trap, a spot in a page where keyboard focus gets stuck and Tab or Shift+Tab can’t move it back out, forcing the user to reload the page to escape, is total: every focusable element inside the container passes focus only to other elements inside the same container, in a closed loop.

This is distinct from a component that deliberately cycles Tab within itself while it’s open, which is the correct and expected behavior for a modal. The defect is the absence of any exit: no Escape handler, no close button that also releases focus, no code path that ever hands focus back to the page.

Why does this matter?

A keyboard trap doesn’t degrade a task, it ends it. Every other keyboard-accessibility failure on this site (a missing label, a confusing tab order, a low-contrast focus ring) still leaves the user able to keep working around it. A trap removes that option entirely: the user cannot Tab forward, Tab backward, or Escape their way anywhere else on the page.

Picture a cookie-consent banner implemented as a trapping modal with a broken close button. A sighted mouse user clicks past it in a second. A keyboard-only user is now locked inside a two-element loop, accept, decline, accept, decline, with the entire rest of the site permanently out of reach until they give up and reload.

Who is affected?

  • Keyboard users: cannot leave the trapped component by any combination of Tab, Shift+Tab, or Escape, and lose access to the rest of the page until they reload.
  • Motor impairments: a switch device or head pointer that drives the page through simulated keyboard input hits the exact same dead end, with no mouse fallback to click free of the loop.
  • Screen reader users: many screen reader users navigate primarily by keyboard, so a trap blocks them the same way it blocks a sighted keyboard user, on top of losing track of where they are once the loop starts repeating announcements.

What users experience

Daniel has a repetitive strain injury that makes using a mouse painful, so he navigates entirely by keyboard. He tabs into a “Notify me” signup modal on a product page and starts filling in his email. When he tries to Tab past the submit button to close the modal and keep browsing, focus loops back to the first field inside it instead. Shift+Tab does the same thing in reverse. He tries Escape; nothing happens, because the developer never wired it up, and eventually reloads the whole page, losing everything he’d typed.

How do I fix it?

Add an Escape key handler to every focus-trapping component that moves focus back to the page and closes or dismisses the component. This works because Escape is the universal, expected release valve for modal keyboard traps: every screen reader user and keyboard power user reaches for it first, so wiring it up removes the dead end at the point people actually test it.

Beyond Escape, make sure the trap itself is temporary by construction: activate it only while the component is genuinely open, and call your focus-trap library’s release or deactivate function in every code path that closes the component, not just the primary close button. A component closed by clicking outside it, by a parent state change, or by a timeout needs to release focus exactly the same way a close-button click does; a trap that only gets released from one of several closing paths is still a trap from the others.

Code Examples

Before
<!-- Modal cycles Tab forever, no Escape handler, no exit -->
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <h2 id="modal-title">Get notified when we launch</h2>
  <input type="email" placeholder="Email address">
  <button>Notify me</button>
  <button class="close-x">×</button>
</div>
After
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <h2 id="modal-title">Get notified when we launch</h2>
  <input type="email" placeholder="Email address">
  <button>Notify me</button>
  <button class="close-x" aria-label="Close">×</button>
</div>
// Escape closes the modal and returns focus to whatever opened it
modal.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    closeModal();
    triggerButton.focus();
  }
});

The markup stays almost identical; the fix is entirely in the missing event listener. Escape now gives every keyboard user a guaranteed way out regardless of which focusable element inside the modal currently has focus, and returning focus to the trigger button restores the exact position the user was at before the modal opened.

Framework Examples

React modals commonly wire up focus trapping with a ref and an effect, and it’s precisely the cleanup half of that effect where this bug tends to slip in: the trap gets activated on mount but never torn down on every unmount path.

function Modal({ isOpen, onClose, triggerRef, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (event) => {
      if (event.key === 'Escape') {
        onClose();
        triggerRef.current?.focus();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    modalRef.current?.focus();

    // Runs on every unmount path, not just the close button:
    // this is what actually releases the trap.
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose, triggerRef]);

  if (!isOpen) return null;
  return <div ref={modalRef} role="dialog" aria-modal="true" tabIndex={-1}>{children}</div>;
}

The effect’s cleanup function is the framework-specific piece worth knowing: React runs it automatically whenever isOpen changes or the component unmounts, so the Escape listener never outlives the modal; a plain script has to remember to remove that listener manually on every closing path.

Common Mistakes

Mistake: “We have a close button, so the trap is fixed.” A visible close button only helps if it’s reachable by keyboard and actually calls the same release logic as every other way the modal can close. A close button that works but sits behind three other unreachable elements, or a modal that can also close itself on a timeout with no matching focus release, still traps keyboard users through every other path.

Mistake: “Focus-trap libraries handle this automatically, so we don’t need to do anything else.” A focus-trap library correctly cycles Tab within the container; that part is automatic. Releasing the trap when the component closes is not; every library requires an explicit deactivate or release call in your own close-handling code, and skipping it anywhere leaves the underlying trap logic still running after the modal visually disappears.

Mistake: “This only matters for modals.” Any component that intercepts keyboard events to control focus can trap: a custom dropdown, an image lightbox, a rich-text toolbar, or an embedded iframe with its own internal Tab handling. The rule applies to any container claiming exclusive control of Tab and Shift+Tab, not specifically to elements with role="dialog".

How RedFlag Detects This

Automated: axe-core rule, runs on every scan. RedFlag calls axe-core’s focus-trap rule as part of every scan, restricted to the WCAG 2.0/2.1/2.2 A and AA rule set (extension/content.js’s AXE_SCAN_OPTIONS). The rule inspects containers that intercept keyboard focus movement and checks for structural signs that Tab and Shift+Tab have no path back out of the container.

False negative: axe-core evaluates the static structure it can see in the DOM and event-handling setup; a trap that only activates conditionally, based on runtime state a static scan doesn’t trigger, can pass automated scanning even though it traps focus during actual use. False positive: a component that intentionally traps focus while genuinely open (the correct modal pattern) can be flagged if axe-core can’t confirm an Escape or close path exists in the code it inspects, even when that path exists but isn’t reachable through the elements axe evaluated. Manual step: open every trapping component and confirm Tab, Shift+Tab, and Escape all provide a real way back to the page before relying on the automated result alone.

Manual Testing

  1. Open the page in Chrome or Firefox with only a keyboard, no mouse.
  2. Tab into every modal, dropdown, and custom widget on the page.
  3. Press Tab repeatedly and confirm focus eventually leaves the component and continues to the rest of the page, rather than looping back to an element already visited inside it.
  4. Press Shift+Tab and confirm the same holds true in reverse.
  5. Press Escape while focus is inside the component and confirm it closes (or otherwise releases focus) and returns focus to a sensible location, typically the element that opened it.
  6. If none of Tab, Shift+Tab, or Escape ever leaves the component, the check fails: reloading the page is not an acceptable exit.

2.1.2 No Keyboard Trap: If keyboard focus can move into a component, it must also be able to move away from it using only the keyboard, and if moving away requires more than unmodified arrow or Tab keys, the user must be advised of the method. This rule is the most direct possible failure of 2.1.2: a component focus can enter but never leave without a full page reload.

2.1.1 Keyboard: All functionality must be operable through a keyboard interface. A trap fails this indirectly: everything outside the trapped component becomes unreachable by keyboard the moment the trap activates with no exit, even though each individual element outside the trap is otherwise perfectly keyboard-operable.

Interactive element is not reachable by keyboard is this rule’s mirror image despite the similar name: that page covers focus that can’t get into an element at all, while this page covers focus that gets in and then can’t get back out.

Modal opens without moving focus into it and Modal is missing focus management cover the other end of the same dialog lifecycle: getting focus into a modal correctly in the first place, which a well-built trap depends on to begin with.

Focusable element is inside an aria-hidden container is a related focus-management defect in modals: content behind a trapping modal that’s still reachable by keyboard because it was never properly hidden or inerted.

Focus skipped an interactive element is a different keyboard failure in the same neighborhood: an element the tab sequence jumps over entirely, rather than a container the tab sequence can’t escape.

References

Frequently asked questions

Is trapping focus inside an open modal always a bug?

No. A modal is supposed to trap focus while it is open, keeping Tab and Shift+Tab cycling through the modal instead of the page behind it. That is correct and expected. The bug is a trap with no exit: no Escape key, no close button, and no code path that ever releases focus back to the page.

Is focus-trap the same issue as redflag-keyboard-trap?

No, despite the similar name. Focus-trap is focus getting into a component and being unable to get back out. redflag-keyboard-trap is a keyboard user being unable to get into an element at all, because it was never made keyboard-focusable in the first place. They are opposite failure directions.

Does pressing Escape have to close the component to fix this?

No. Escape has to release focus, but it does not have to close anything. A trap is fixed the moment Tab and Shift+Tab (or an Escape handler) can move focus back to the rest of the page; some components legitimately stay open while focus moves elsewhere, such as a persistent search panel.

Can a focus-trapping library still cause this bug?

Yes. Focus-trap libraries correctly cycle Tab within a container, but every one of them requires you to call their own release or deactivate function when the component closes. Forgetting that call, or closing the component through a code path that skips it, leaves the trap active with nothing left to escape from.

Does this apply to iframes as well as modals?

Yes. An iframe with no way to Tab back out to the parent page, because the embedded content itself has no exit point, or intercepts Tab and Shift+Tab internally, creates the same trap for keyboard users, even though nothing about it looks like a modal.