Accessibility

Focus trapping in a modal: how it really works

A focus trap keeps Tab and Shift+Tab inside the open modal and returns focus to the trigger on close. The native dialog element does it with showModal().

Key points

  • Criterion 2.1.2 No Keyboard Trap, level A, requires focus to always be able to leave: Escape must close.
  • dialog with showModal() makes the rest of the page inert and handles the Escape key without code.
  • On close, focus returns to the trigger; if the trigger is re-rendered, restore it yourself.

A focus trap is the rule that keeps Tab and Shift+Tab inside an open modal and stops focus from reaching the page underneath. The shortest way to get one is <dialog> with showModal(): the browser makes the rest of the document inert and closes on Escape.

What a modal must do

Four behaviours, not one. Focus enters the modal on open. Tab cycles only through the controls inside. Escape closes, because 2.1.2 No Keyboard Trap, level A, forbids trapping focus with no way out. On close, focus returns to the control that opened the dialog, as 2.4.3 Focus Order, level A, requires.

The native dialog

Open settings


  Settings
  Name
  
  Close



  const dialog = document.getElementById('settings');
  const trigger = document.getElementById('open');
  trigger.addEventListener('click', () => dialog.showModal());
  dialog.addEventListener('close', () => trigger.focus());

showModal() does three things a div does not: it applies the ::backdrop style, makes the rest of the page inert and intercepts Escape. The browser returns focus to the element that was active before opening; the close listener covers the case where a re-render replaced that node. The container has aria-labelledby pointing at the title: without an accessible name the reader announces “dialog” and nothing else.

Where hand-built traps break

  • Iframes and shadow DOM: a keydown listener cannot see focus inside an iframe, so the cycle stops.
  • Removed content: if the first or last control disappears, the cycle points at a node that no longer exists.
  • Stacked modals: two traps compete for Tab; the native dialog handles the stack through the top layer.

The manual test

Open the modal with the keyboard, not the mouse. Press Tab to the last control and once more: focus must return to the first, not move to the page. Press Escape and check that focus sits on the trigger button. Repeat with Shift+Tab. Criterion 2.4.11 Focus Not Obscured (Minimum), level AA, adds a check tools cannot do: the indicator must not stay covered by a sticky header or by the modal itself.

npx --yes lighthouse https://example.com --only-categories=accessibility --chrome-flags="--headless" --output=json --output-path=./a11y.json

Lighthouse is open source under the Apache-2.0 licence and measures the accessibility of the rendered page. It does not open the modal and does not press Tab: the report can be clean while the trap is broken.

The EneUI catalogue

EneUI indexes the dialog components, the modals and the Origin UI dialog record, with an MIT licence declared upstream: it depends on @radix-ui/react-dialog and does not reinvent the trap. Checked on 1 November 2026 against 111,985 components.

The limitation

The native dialog does not fix reading order: if the internal markup puts the title after the content, the screen reader reads in the wrong order. It also does not cover non-modal panels that leave the page interactive: there a trap is the wrong choice, because it takes away controls the user should still reach.

Where to continue

The shadcn case is in an accessible dialog with shadcn; portals are in React portals for modals. The comparison of ready-made blocks is in the best dialog blocks.

Frequently asked questions

Is closing with Escape mandatory?

Yes, at level A: 2.1.2 No Keyboard Trap requires a way out with the keyboard. Escape is the convention, and the native dialog implements it on its own.

Do I need a listener to cycle Tab?

No, if you use dialog with showModal(). The browser makes the rest of the page inert, so Tab stays inside without extra code. A listener is only needed for non-native containers.

Where does focus go on open?

To the first useful control, usually the field or the main button. The title is read through aria-labelledby and does not take focus: keyboard users should not have to pass through a non-interactive title.

Does focus return to the trigger by itself?

Yes, with showModal(): the browser remembers the active element from before the open. If the trigger is unmounted and recreated, restore focus in the close handler, otherwise it lands on the body.

Can a modal have more than one focus trap?

No. Two stacked traps compete for Tab and the result depends on listener order. With nested dialogs the top layer manages the stack; with divs you need a single handler that knows the active level.