How to Test Focus Traps, Restore Focus, and Escape Key Behavior in Modal-Heavy Frontends
By Antoine Dubois · October 25, 2026
A browser-focused guide to testing modal focus traps, tab order, Escape-to-close, focus restoration, nested dialogs, and portal edge cases with Playwright.
A modal can look correct and still be broken for keyboard users. The failure usually is not visual, it is behavioral, focus enters the dialog but does not stay there, Escape closes the overlay but leaves focus behind, or closing the modal does not restore focus to the trigger that opened it.
If you want to test focus trap in browser automation, the useful question is not “does the dialog render?” It is “what can the keyboard do before, during, and after the modal is open?” That shifts the test from pixel checking to browser behavior, which is what accessibility and keyboard users actually depend on.
For the rest of this article, I will use modal to mean any overlay that captures interaction until it is dismissed, including drawers and dialog-like popovers. I will use focus trap to mean the rule that keyboard focus must remain inside the active overlay until it closes.
What good modal keyboard behavior looks like
The WCAG success criteria most relevant here are about keyboard access, focus order, and visible focus, especially WCAG 2.x. The exact implementation can vary, but the browser-visible behavior should be consistent:
- Opening the modal moves focus into it, usually to the first meaningful control or a heading with
tabindex="-1". TabandShift+Tabcycle only through elements inside the modal.Escapecloses the modal if the design supports it, and closes the topmost modal first when dialogs are nested.- Closing the modal restores focus to the element that opened it, or to a documented fallback if that element no longer exists.
- Background content is not reachable by keyboard while the modal is active.
The trap is not the overlay itself. The trap is the keyboard path through the document.
That is why browser automation is a good fit. You are asserting focus state, active element transitions, and keyboard navigation, not a visual approximation of a trap.
The minimum assertions worth keeping
I would keep four assertions for every critical modal flow:
- Initial focus lands inside the modal
- Tab order does not escape the modal
- Escape closes the topmost modal
- Focus returns to the invoker after close
If you only test one of these, the rest can still regress independently. A modal can open correctly and still leak focus. It can trap focus and still fail to restore it. It can restore focus and still ignore Escape.
A practical Playwright example
The example below assumes a modal opened from a button and rendered in a portal. It avoids brittle selectors by checking browser focus, keyboard behavior, and the dialog role.
import { test, expect } from '@playwright/test';
test('modal traps focus, closes on Escape, and restores focus', async ({ page }) => {
await page.goto('/settings');
const openButton = page.getByRole('button', { name: 'Open preferences' });
await openButton.click();
const dialog = page.getByRole('dialog', { name: 'Preferences' });
await expect(dialog).toBeVisible();
await expect(dialog.locator('button, input, select, textarea, [tabindex]:not([tabindex="-1"])').first())
.toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog).toContainText('Keyboard shortcuts');
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Tab');
}
await expect(dialog).toBeVisible();
await expect(page.getByRole('button', { name: 'Open preferences' })).not.toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(openButton).toBeFocused();
});
This example uses a few important ideas:
getByRole('dialog')anchors the test to the accessibility tree instead of a CSS class.toBeFocused()checks the browser’s actual active element.- Repeated
Tabpresses catch focus leaks that only appear after wraparound. Escapeis verified as a keyboard action, not a click on a close button.
Why repeated Tab presses matter
A focus trap often fails at the edges, not in the middle. One Tab press only proves that the next element is still inside the modal. A wraparound loop proves that the last focusable element returns to the first and never lands in the page behind it.
If your modal contains a scrollable region, custom selects, or a rich text editor, I would add a second pass with Shift+Tab as well. Some trap implementations accidentally block forward movement but allow backward escape.
What to assert when portals are involved
Portals are not a problem by themselves. They are a problem when tests assume the modal stays inside the same DOM subtree as the trigger.
For portal-based modals, do not assert ancestry like trigger.parentElement.contains(dialog). Instead, assert behavior:
- the dialog is attached somewhere in the document
- it is visible and reachable by role
- focus enters the dialog after open
- background controls are not focusable during the trap
If the modal lives in a separate root, locators should still work as long as the browser accessibility tree exposes the dialog properly. That is one reason role-based queries are more stable than DOM traversal.
A quick diagnostic for portal bugs
If a portal modal is failing tests, check these before rewriting the test:
- Is the dialog rendered before animation completes, but still hidden with
opacity: 0orvisibility: hidden? - Is focus moved only after a timeout, which makes the test race the animation?
- Does the close action unmount the modal before focus restoration runs?
- Is the trigger element removed and re-created, which breaks focus return by reference?
Animation wrappers and why they create false failures
Animation wrappers often delay visibility, mounting, or pointer handling. That can create a test that passes visually in a real browser but fails if you assert too early.
What to wait for depends on the implementation:
- If the modal is mounted immediately and animated in, wait for visibility and focus, not for DOM insertion.
- If the modal is mounted after animation starts, wait for the dialog role to appear, then assert focus.
- If closing uses an exit animation, wait for the dialog to be hidden or detached before checking that focus has returned.
A helpful pattern is to wait on the semantic state you care about, not on a guessed timeout.
await expect(page.getByRole('dialog', { name: 'Preferences' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open preferences' })).toBeFocused();
That second assertion looks odd until you close the modal. It is a reminder that focus restoration is a state transition, not a visual event.
Nested dialogs and Escape key precedence
Nested dialogs are where modal behavior gets subtle. A confirmation dialog inside a settings dialog should not close both at once unless that is explicitly designed.
The test should verify the topmost dialog closes first:
- Open dialog A
- Open dialog B from inside A
- Press Escape
- Assert B closes, A remains open, and focus returns to a control inside A
- Press Escape again
- Assert A closes and focus returns to its trigger
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog', { name: 'Confirm discard' })).toBeHidden();
await expect(page.getByRole('dialog', { name: 'Preferences' })).toBeVisible();
If Escape closes the wrong layer, users lose context fast. That is not just a UX bug, it is a keyboard accessibility bug.
Focus restoration to the trigger, with one important exception
Focus restoration testing is usually straightforward, but there is a failure mode that deserves explicit handling. The trigger may disappear after the modal closes, for example a row action button inside a deleted table row.
In that case, restoration should follow a documented fallback, such as:
- the next logical control in the same region
- the list or table container
- a heading with
tabindex="-1"
Your test should reflect the product behavior you want, not force focus back to a node that no longer exists.
If the trigger is removed from the DOM, testing “focus returns to the trigger” is the wrong assertion. Test the fallback focus target instead.
A good pattern is to write two separate tests, one for the normal case, one for the disappearing-trigger case. They cover different risk paths.
What not to test
Do not turn modal accessibility tests into implementation tests unless you need to. Avoid these brittle assertions:
- exact CSS classes on open and close
- animation duration values
- DOM position relative to the trigger
- fixed tab counts, unless the dialog content is static and intentionally small
These checks are easy to write and expensive to maintain. They fail when markup changes but the keyboard behavior stays correct.
A lightweight checklist for modal-heavy frontends
Use this checklist when adding or reviewing tests:
- The modal has a clear accessible name, usually via
aria-labelledbyor a visible heading. - Opening the modal moves focus inside it.
- Tab and Shift+Tab stay trapped.
- Escape closes the topmost modal.
- Focus returns to the opener, or to an explicit fallback.
- Portal rendering and animations are handled with semantic waits, not arbitrary delays.
- Nested dialogs have a specific Escape order.
When Selenium or Cypress may still be the better fit
Playwright is a strong default for keyboard accessibility browser tests because it exposes focus and role-based locators cleanly, but it is not the only reasonable choice.
- Cypress can work well if your team already standardizes on it and your modal behavior stays within Cypress’s supported browser model.
- Selenium can be the better choice if your organization needs broad language support or an existing grid-based setup.
What matters is not the brand of framework, it is whether the framework lets you express focus state and keyboard navigation clearly enough that failures are readable.
Bottom line
If a modal is important enough to ship, it is important enough to test as a keyboard interaction, not just a visual overlay. The most durable browser automation checks are the ones that follow user behavior: focus enters the modal, stays trapped, Escape closes the topmost layer, and focus returns somewhere sensible after close.
That gives you coverage against the bugs users actually feel, while keeping the test stable when the component changes shape, moves into a portal, or adds animation.
FAQ
How do I verify a focus trap without checking every tab stop manually?
Use a small loop that presses Tab through more steps than the dialog has visible controls, then assert the dialog stays visible and the page behind it never receives focus.
What is the best selector for modal tests?
Prefer role-based selectors such as getByRole('dialog') and getByRole('button', { name: ... }). They align with the accessibility tree and usually survive markup refactors better than CSS selectors.
Should Escape always close a modal?
Only if that is part of the product contract. If Escape is supported, test that it closes the topmost modal first and does not skip over nested layers.
How do I test focus restoration when the trigger disappears?
Assert the documented fallback focus target, such as the container, list region, or next control. Do not require focus to return to a node that no longer exists.
Why do modal tests fail after adding animations?
Animations can delay visibility, focus movement, or unmounting. Wait on semantic states like visible, hidden, focused, or detached, instead of using fixed sleep values.