Accessibility Testing Guide

How to Test Modal Focus Trap with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing modal focus traps with Playwright. Tab cycle containment, Escape key close, focus restore on dismiss, nested modals, scroll lock on the body, and ARIA attributes that screen readers depend on.

70%+

Over 70% of the top 1,000,000 home pages have detectable WCAG 2 failures according to the WebAIM Million 2025 report, with dialog and modal issues among the most common interactive accessibility problems.

WebAIM Million 2025

0Focus trap scenarios covered
0%Sites with a11y issues (WebAIM)
0ARIA attributes required per modal
0%Fewer lines with Assrt

Modal Focus Trap Lifecycle

UserTrigger ButtonModal DialogFocus TrapBody ScrollClick open buttonRender modal + backdropActivate focus trapLock body scrollTab through focusable elementsCycle focus back to first elementPress EscapeReturn focus to trigger

1. Why Testing Modal Focus Traps Is Harder Than It Looks

A modal dialog looks simple on the surface: render a box, dim the background, show some content. But the keyboard behavior that makes a modal accessible is a tightly choreographed sequence of focus management events. The WAI-ARIA Dialog pattern (APG) requires that when a modal opens, focus moves to the first focusable element inside it. When the user presses Tab at the last focusable element, focus must wrap back to the first. When the user presses Shift+Tab at the first element, focus must wrap forward to the last. When the user presses Escape, the modal must close and focus must return to the element that originally triggered it.

That is already four distinct behaviors to test, and it gets more complex from there. The modal must set aria-modal="true" and role="dialog" on the container. It must have an accessible name via aria-labelledby or aria-label. The body behind the modal must prevent scrolling (typically via overflow: hidden on the document body). Nested modals, where a confirmation dialog opens inside a settings modal, require a stack of focus traps where only the topmost trap is active.

The reason these behaviors are hard to test with Playwright specifically is that Playwright's page.keyboard.press dispatches real keyboard events, but verifying which element received focus requires checking document.activeElement via page.evaluate(). Standard Playwright locator assertions like toBeVisible do not tell you whether an element is focused. You need toBeFocused() or manual evaluation of the active element. Many teams skip these assertions entirely, resulting in modals that look correct visually but fail completely for keyboard users.

Focus Trap Behavior Requirements

🌐

Modal Opens

Focus moves to first focusable

↪️

Tab Forward

Cycle through elements

↪️

Last Element

Tab wraps to first

↪️

Shift+Tab

Wraps to last element

🔒

Escape Key

Modal closes

Focus Restored

Returns to trigger

2. Setting Up Your Test Environment

Focus trap testing requires a few specific configuration choices in your Playwright setup. First, you need a real browser context with keyboard event support. Playwright's Chromium, Firefox, and WebKit engines all handle focus events, but their behavior on Tab key presses differs slightly. Run your focus trap tests across all three engines to catch browser-specific edge cases.

The test page should contain a well-structured modal component. For this guide, we will use a modal with a title, a text description, two form inputs, a cancel button, and a confirm button. That gives us six focusable elements inside the trap, which is enough to exercise the full Tab cycle logic.

Project Setup
playwright.config.ts

Helper Utilities for Focus Testing

Focus assertions come up repeatedly, so extract a small helper module that wraps the common patterns. This keeps individual test files concise and readable.

tests/helpers/focus-utils.ts

Test Environment Setup Flow

⚙️

Install Playwright

npm init playwright

🌐

Configure Browsers

Chromium, Firefox, WebKit

Create Helpers

Focus utility functions

🌐

Prepare Test Page

Modal with 6 focusables

Run Tests

Cross-browser

1

Tab Cycle Containment

Moderate

Goal

Verify that pressing Tab repeatedly inside an open modal cycles through every focusable element and then wraps back to the first, never allowing focus to escape to elements behind the modal backdrop.

Preconditions

The modal must be open with at least three focusable elements inside it (a close button, one or more form controls, and an action button). The trigger button behind the modal must not be reachable via Tab while the modal is open.

Playwright Implementation

tests/a11y/modal-focus-trap.spec.ts

What to Assert Beyond the UI

Tab Containment Assertions

  • Focus never reaches elements outside the dialog
  • Tab order matches visual order (top-left to bottom-right)
  • Hidden or disabled elements are skipped
  • Dynamically added elements become part of the cycle
2

Reverse Tab (Shift+Tab) Wrapping

Moderate

Goal

Verify that pressing Shift+Tab at the first focusable element inside the modal wraps focus to the last focusable element, and that continuing Shift+Tab traverses the list in reverse order.

Playwright Implementation

tests/a11y/modal-focus-trap.spec.ts

Tab Containment: Playwright vs Assrt

test('Shift+Tab wraps from first to last', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Open Settings' }).click();
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  const focusableIds = await page.evaluate(() => {
    const dialog = document.querySelector('[role="dialog"]');
    if (!dialog) return [];
    const els = dialog.querySelectorAll(
      'button, input, select, textarea, a[href], [tabindex]'
    );
    return Array.from(els).map(el => el.getAttribute('data-testid'));
  });
  await expect(
    page.locator(`[data-testid="${focusableIds[0]}"]`)
  ).toBeFocused();
  await page.keyboard.press('Shift+Tab');
  await expect(
    page.locator(`[data-testid="${focusableIds.at(-1)}"]`)
  ).toBeFocused();
});
67% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
3

Escape Key Closes the Modal

Straightforward

Goal

Verify that pressing Escape while the modal is open closes the modal, removes the backdrop overlay, and re-enables body scroll. This is one of the most commonly broken modal behaviors: many implementations capture Escape on the modal container but fail to handle it when focus is on a nested input or when a dropdown inside the modal is open.

Playwright Implementation

tests/a11y/modal-escape.spec.ts

What to Assert Beyond the UI

Closing the modal is not just about visibility. The DOM should also remove the aria-hidden="true" attribute from the main content area. If your implementation uses inert on the background content, that attribute must also be removed. And body scroll must be re-enabled. These are the assertions that separate a visual test from a real accessibility test.

4

Focus Restores to Trigger on Close

Complex

Goal

Verify that after the modal closes (by Escape, close button, or backdrop click), focus returns to the element that opened the modal. This is required by WCAG 2.1 Success Criterion 2.4.3 (Focus Order) and the WAI-ARIA Authoring Practices for dialogs. Without focus restore, a keyboard user is stranded at the top of the page after dismissing a modal, losing their place entirely.

Playwright Implementation

tests/a11y/modal-focus-restore.spec.ts

Focus Restore: Playwright vs Assrt

test('focus returns to trigger after Escape', async ({ page }) => {
  await page.goto('/');
  const trigger = page.getByRole('button', { name: 'Open Settings' });
  await trigger.click();
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  await page.keyboard.press('Escape');
  await expect(modal).not.toBeVisible();
  await expect(trigger).toBeFocused();
});
30% fewer lines
5

Nested Modals and Stacked Traps

Complex

Goal

Verify that when a second modal opens on top of an existing modal (for example, a confirmation dialog inside a settings panel), the focus trap activates on the top modal only, the bottom modal becomes inert, and closing the top modal restores focus to the element in the bottom modal that triggered the nested dialog.

Nested modals are where most focus trap libraries break down. Libraries like focus-trap and react-focus-lock support trap stacking, but custom implementations often fail by releasing the trap entirely when the inner modal closes, leaving focus unmanaged.

Playwright Implementation

tests/a11y/modal-nested.spec.ts
Nested Modal Test Run

The terminal output above shows the most common failure with nested modals: the inner dialog renders but does not move focus into itself. The fix is to call .focus() on the first focusable element inside the nested dialog after it mounts, or to use a focus trap library that handles stacking automatically. If your test catches this failure, you have already found a real accessibility bug.

6

Scroll Lock and ARIA Attributes

Moderate

Goal

Verify that when the modal is open, the page behind it cannot scroll, and the correct ARIA attributes are present on the dialog container and the obscured background content. These are the attributes screen readers use to determine what content is interactive and what is inert.

Playwright Implementation: Scroll Lock

tests/a11y/modal-scroll-lock.spec.ts

Playwright Implementation: ARIA Attributes

tests/a11y/modal-aria.spec.ts

ARIA Validation: Playwright vs Assrt

test('modal has required ARIA attributes', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Open Settings' }).click();
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  await expect(modal).toHaveAttribute('aria-modal', 'true');
  const labelledBy = await modal.getAttribute('aria-labelledby');
  expect(labelledBy).toBeTruthy();
  const titleText = await page.locator(`#${labelledBy}`).textContent();
  expect(titleText!.length).toBeGreaterThan(0);
  const mainContent = page.locator('main');
  const isInert = await mainContent.getAttribute('inert');
  const isAriaHidden = await mainContent.getAttribute('aria-hidden');
  expect(isInert !== null || isAriaHidden === 'true').toBe(true);
});
57% fewer lines

9. Common Pitfalls That Break Focus Trap Tests

Timing: Focus Moves Before the Animation Completes

Most modal libraries animate the dialog in with a CSS transition (opacity, transform, or both). If you assert focus immediately after triggering the modal open, the element may not yet be focusable because it has visibility: hidden or display: none during the first frame of the animation. Use await expect(modal).toBeVisible() before checking focus. In some frameworks, you may also need await page.waitForTimeout(100) to let the CSS transition settle, though this should be a last resort.

Portal-Rendered Modals Break DOM Queries

React portals, Vue Teleport, and similar patterns render the modal DOM at the end of the document body rather than inside the component tree. This means a query like modal.locator('button') may return zero results if the modal is not the direct DOM parent. Always query from page level using getByRole('dialog') first, then scope subsequent locators to that dialog locator. Never assume the modal is a child of the component that triggered it.

Shadow DOM Swallows Focus Events

Web components that render modals inside shadow DOM create a separate focus scope. document.activeElement will point to the shadow host, not the actual focused element inside the shadow tree. You need to traverse document.activeElement.shadowRoot?.activeElement recursively to find the real focused element. The focus helper utility from Section 2 can be extended to handle this case.

iOS Safari and VoiceOver Focus Behavior

WebKit on iOS handles focus differently from desktop browsers. VoiceOver can move focus outside a focus trap by swiping, because the swipe gesture traverses the accessibility tree rather than dispatching keyboard events. The inertattribute on background content is the only reliable way to prevent this. If your modal implementation uses only JavaScript keydown listeners for trapping, it will break on iOS VoiceOver. Test on real devices or use Playwright's WebKit engine as a first-pass proxy, though it does not fully replicate VoiceOver behavior.

Multiple Modals from Different Libraries

Large applications sometimes use multiple UI libraries (a design system dialog, a third-party date picker modal, a custom confirmation dialog). Each library registers its own focus trap listener. When two traps compete, focus can oscillate between them in an infinite loop or get stuck on a single element. If your test detects a focus assertion failure that only happens intermittently, check whether two focus trap handlers are active simultaneously. Standardize on one focus trap mechanism across your application.

Anti-patterns That Break Focus Traps

  • Using tabindex="0" on the modal container instead of managing focusable children
  • Forgetting to restore focus when modal is closed programmatically (not via Escape)
  • Setting aria-hidden on the dialog itself instead of on the background content
  • Using pointer-events: none instead of inert for background content
  • Not removing scroll lock when component unmounts (memory leak)
  • Focus trap activates before the modal animation completes
Full Focus Trap Test Suite

10. Writing These Scenarios in Plain English with Assrt

The six scenarios above add up to over 300 lines of Playwright TypeScript. Each test uses page.evaluate() calls to inspect document.activeElement, manual traversal of focusable element lists, and fragile selectors like [data-testid="cancel-btn"]. When your design team changes the modal layout, renames a button, or swaps the focus trap library, every one of those tests breaks silently. Assrt lets you describe the accessibility requirements in plain English and regenerates the Playwright implementation when the underlying DOM changes.

The nested modal scenario from Section 7 is a good example. In raw Playwright, you need to know the exact accessible name for both dialogs, the data-testid of the cancel button in the inner modal, and the correct Tab count to verify wrapping. In Assrt, you describe the intent: open the confirmation dialog inside the settings modal, verify that Tab cycles only within the confirmation dialog, close it, and verify focus returns to the settings modal.

scenarios/modal-focus-trap.assrt

Assrt compiles each scenario block into the Playwright TypeScript shown in the preceding sections and commits it to your repo as real tests you can read, run, and extend. When your design system updates the modal component (renaming the close button, changing the focus trap library, adding new focusable elements), Assrt detects the assertion failures, analyzes the new DOM structure, and opens a pull request with updated locators. Your scenario files stay untouched.

Start with the Tab cycle containment test. Once it passes in CI, add Shift+Tab wrapping, then Escape close, then focus restore, then nested modals, then scroll lock and ARIA validation. In a single afternoon you can have comprehensive focus trap coverage that catches the accessibility regressions most teams never test for at all.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk