A modal can look correct and still fail the user experience in exactly the places automation often ignores: Tab escapes the dialog, Escape does nothing, focus disappears into the page behind it, or the page keeps scrolling under an open overlay. Click-only assertions miss most of that. If you want to test keyboard traps in browser automation, the reliable signal is not whether the close button clicked successfully, but whether focus, key handling, and background behavior changed in the right order.

For modal accessibility testing, I usually treat three things as non-negotiable:

  1. Focus must move into the dialog when it opens.
  2. Tab and Shift+Tab must stay within the expected focus cycle while it is open.
  3. When it closes, focus must return to a sensible origin and background interaction must resume only when appropriate.

Those expectations line up with the W3C accessibility guidance on dialogs and keyboard interaction, especially when a modal is used to block the rest of the page. The exact implementation can vary, but the observable behavior should not. See the WCAG guidance and the dialog patterns in the WAI-ARIA Authoring Practices for the underlying interaction model.

What to verify, and why click assertions are not enough

A click assertion answers a narrow question, usually “did the button respond?” That leaves out the user journey that matters for accessibility.

For a modal or dialog, verify these outcomes instead:

  • Initial focus placement: opening the dialog should place focus on the dialog itself, a heading, or the first interactive control, depending on design.
  • Keyboard trap behavior: Tab and Shift+Tab should cycle through only the controls inside the dialog.
  • Escape key modal dismissal: if the product supports Escape, the dialog should close consistently and restore the page state.
  • Focus return after dialog close: focus should go back to the opener or another intentional target, not the document body.
  • Background lock: the page behind the modal should not scroll, receive clicks, or be reachable through sequential keyboard navigation while the modal is active.
  • Nested dialog behavior: when a second dialog opens from inside the first, closing the nested one should restore focus to the correct place in the parent dialog.

If you only assert that the close button exists and can be clicked, you have not tested the modal. You have tested a mouse path into one control.

The basic pattern: inspect focus, send keys, assert state

The most stable browser automation pattern for this problem is simple:

  • open the modal with the same action a user would take,
  • read the active element or a clear focus marker,
  • send keyboard events to move focus,
  • assert the active element after each step,
  • confirm the modal closed or stayed open for the right reason.

Playwright is a good fit here because it can press keys against the page and inspect DOM state directly. The same idea works in Cypress or Selenium, but the API details differ.

Example: modal opens with the first focus target inside

import { test, expect } from '@playwright/test';
test('modal receives initial focus', async ({ page }) => {
  await page.goto('/settings');

  await page.getByRole('button', { name: 'Edit profile' }).click();

  const dialog = page.getByRole('dialog', { name: 'Edit profile' });
  await expect(dialog).toBeVisible();

  const activeRole = await page.evaluate(() => document.activeElement?.getAttribute('role'));
  expect(activeRole === 'dialog' || activeRole === null).toBeTruthy();
});

That last assertion is intentionally conservative. In many implementations the dialog itself takes focus, while in others a heading or first input does. If your component library exposes a deterministic focus target, assert that target directly rather than relying on a vague assumption.

How to test the keyboard trap itself

The key test is whether repeated Tab presses stay inside the modal. Do not just press Tab once. That only proves one step in the loop.

A practical version is to enumerate the interactive controls you expect, then cycle through them in order.

import { test, expect } from '@playwright/test';
test('tab stays trapped inside the modal', async ({ page }) => {
  await page.goto('/settings');
  await page.getByRole('button', { name: 'Edit profile' }).click();

  const expectedOrder = [
    'First name',
    'Last name',
    'Save changes',
    'Cancel'
  ];

  for (const label of expectedOrder) {
    await page.keyboard.press('Tab');
    await expect(page.getByRole('button', { name: label }).or(page.getByLabel(label))).toBeFocused();
  }
});

That example assumes a sane focus order and accessible names. If your modal contains custom controls, the assertions should target the actual tabbable elements, not only visual labels.

If the dialog has a close button, include a wraparound check as well. A proper trap should move from the last tabbable control back to the first, or to the designated initial control, without escaping to the page behind it.

Shift+Tab matters too

A surprising number of implementations only trap forward Tab, which still leaves a broken experience. Verify reverse traversal as well.

await page.keyboard.press('Shift+Tab');
await expect(page.getByRole('button', { name: 'Cancel' })).toBeFocused();

If Shift+Tab escapes to the browser chrome, or lands on an element outside the modal, the trap is incomplete.

Testing Escape key modal dismissal the right way

Escape should be tested as a state transition, not just a key event.

The expected sequence is:

  1. modal open,
  2. Escape key pressed,
  3. modal closes,
  4. focus returns to a known target,
  5. background interaction resumes if the modal was truly blocking.
import { test, expect } from '@playwright/test';
test('Escape closes the dialog and returns focus', async ({ page }) => {
  await page.goto('/settings');

  const opener = page.getByRole('button', { name: 'Edit profile' });
  await opener.click();

  const dialog = page.getByRole('dialog', { name: 'Edit profile' });
  await expect(dialog).toBeVisible();

  await page.keyboard.press('Escape');

  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();
});

This is more useful than checking for a close animation, because it validates the accessible contract. If your product intentionally does not close on Escape, document that behavior and test the opposite outcome, namely that the dialog remains open and focus stays inside.

Focus return after dialog close is the part most teams miss

A dialog that closes cleanly but drops focus to body still creates a keyboard dead end. After close, a user pressing Tab should continue from a meaningful place, usually the element that opened the dialog.

A robust assertion should check the opener, or another agreed return target, immediately after dismissal. Do not wait for the next click to reveal the bug.

Failure modes worth covering

  • Stale focus: the dialog closes, but the browser keeps focus on a hidden element that no longer exists.
  • Wrong return target: focus returns to a different control than the opener, which can be confusing in multi-step flows.
  • Focus jump to page top: the browser resets focus in a way that looks like a page refresh to keyboard users.

If your app renders the opener conditionally, the return target may be the next logical control in the workflow. In that case, write the expectation into the test explicitly. The point is not “back to opener” as a rule, it is “back to an intentional, visible, keyboard-reachable place.”

Test background scroll lock and pointer isolation separately

A modal can trap focus correctly and still fail because the page behind it scrolls or clicks.

A scroll-lock check is simple and valuable:

const before = await page.evaluate(() => window.scrollY);
await page.keyboard.press('PageDown');
const after = await page.evaluate(() => window.scrollY);
expect(after).toBe(before);

If the layout allows body scrolling under the dialog, this assertion will usually expose it. For pointer isolation, attempt to click a known behind-the-modal element and assert that it does not receive action while the dialog is open. Prefer a state-based check over brittle pixel or overlay assumptions.

A useful distinction here:

  • Focus trap protects keyboard users.
  • Backdrop or scroll lock protects the rest of the page state.

You need both if the modal is truly blocking.

Nested dialogs need explicit focus history

Nested dialogs are where modal tests often become flaky, because there are two valid focus histories:

  • the outer dialog remains mounted and regains focus,
  • or the inner dialog closes and returns focus to a control inside the outer dialog.

Your tests should encode the actual product rule, not a generic guess.

One reliable approach is to record the opener before each modal transition, then assert the return target after each close. For example:

  1. open outer dialog,
  2. focus on Open advanced options,
  3. open inner dialog,
  4. close inner dialog with Escape,
  5. assert focus returns to Open advanced options,
  6. close outer dialog,
  7. assert focus returns to the page opener.

That ordering catches the common bug where the nested dialog closes correctly but focus jumps to the wrong layer.

A compact decision table for modal tests

Behavior What to assert Common failure signal
Initial focus active element after open focus remains on opener or falls to body
Keyboard trap repeated Tab and Shift+Tab stay inside focus escapes to page content
Escape dismissal modal closes on Escape when supported keypress does nothing or closes wrong layer
Focus return opener or intended target regains focus focus lost, hidden, or jumps unpredictably
Background lock scroll and background clicks are blocked page scrolls or behind-content responds
Nested modal close focus returns to correct parent control focus lands on the wrong dialog or page element

A few implementation rules that reduce flakiness

Prefer role-based locators and stable labels

Modal tests are easier to maintain when the dialog and controls expose stable accessible names. getByRole('dialog', { name: ... }) is usually more resilient than CSS selectors, especially if the DOM structure changes.

Avoid fixed sleeps

Do not use arbitrary timeouts to wait for the dialog to open or close. Wait for a visible state change or a focused element. If the dialog closes with an animation, wait for the end state, not the animation duration.

Keep the test close to the interaction contract

A modal accessibility test should not validate styling details, CSS classes, or exact animation timing unless those properties affect the interaction contract. The smaller the assertion surface, the lower the maintenance cost.

Know when a framework limitation is really an app bug

If a keyboard event appears to do nothing, check whether the problem is in the application, not the automation layer. Automation frameworks can send key presses, but they cannot fix a modal that never receives focus or that fails to wire Escape to a close action.

When to tighten the assertion, and when not to

Tighten the test if the dialog has a strict accessibility spec, for example a form wizard, consent flow, or destructive confirmation. In those cases I would assert the exact focus order, exact return target, and Escape behavior.

Loosen the test if the dialog is intentionally non-modal, or if the product allows multiple valid focus paths. Then assert the minimum user-visible contract, such as “focus remains inside while open” and “close returns to a visible element.”

A simple checklist you can reuse

Before you ship a dialog test, confirm that it checks:

  • open state with keyboard-accessible locators,
  • initial focus,
  • Tab and Shift+Tab cycling,
  • Escape dismissal if the design supports it,
  • focus return after dialog close,
  • background scroll lock or pointer isolation,
  • nested dialog return behavior when relevant.

That checklist is small enough to keep in a test plan, but specific enough to catch the bugs that users actually feel.

Final judgment

If your goal is to test keyboard traps in browser automation, start from focus, not from clicks. Clicks can confirm that a control exists. Focus and keyboard assertions confirm that the modal behaves like a real dialog for real users.

The best return on effort usually comes from a few carefully chosen state assertions, not from exhaustive UI choreography. Verify the focus path, the dismissal path, and the recovery path. If those pass consistently, your modal tests are doing useful work instead of just replaying a mouse script.

FAQ

How do I detect a keyboard trap failure?

Press Tab repeatedly and assert that focus never leaves the dialog until it is dismissed. If focus lands on background content, the trap is broken.

Should every modal close on Escape?

Not necessarily. If Escape is supported, test that it closes the dialog. If the product intentionally blocks Escape for a specific reason, test the documented non-dismissal behavior instead.

What is the best focus target after a dialog closes?

Usually the opener, or another intentional control in the workflow. The right target is the one that lets a keyboard user continue without losing context.

How do I test background scroll lock?

Record scrollY, send a navigation key such as PageDown, then assert the page did not move while the modal was open.

Are nested dialogs worth testing separately?

Yes. Nested dialogs have their own focus history, and bugs often appear only when closing the inner layer and returning to the parent dialog.