A modal dialog is only accessible if keyboard users can enter it, move inside it, close it, and return to where they were without losing context. That sounds simple, but the three failures that break real workflows are easy to miss: focus escapes the dialog, Escape does nothing or closes the wrong layer, and focus is not restored to the trigger after close.

This guide shows how to test accessible modal dialogs with browser automation, what to assert in code, and what still needs manual keyboard checks. It also covers the edge cases that produce false positives, especially when the modal renders in a portal, sits inside another overlay, or closes as part of a route change.

What an accessible modal must do

For test purposes, separate the modal’s visual behavior from its accessibility behavior.

A modal dialog should normally:

  • Move focus into the dialog when it opens
  • Keep tab focus trapped inside the dialog until it closes
  • Close on Escape when the dialog is active, unless your product has an explicit exception
  • Return focus to the trigger or the most logical originating control after close
  • Expose the dialog semantics correctly, usually with role="dialog" or role="alertdialog", plus an accessible name

These expectations align with the WAI-ARIA Authoring Practices and the keyboard and focus expectations in the WCAG standards.

If your test only checks that the modal appears and disappears, it is not testing accessibility. It is only testing visibility.

The three failure points to test first

1) Focus trap testing

A working focus trap means that Tab and Shift+Tab cycle through the focusable elements inside the dialog and never land on elements behind it.

What to assert:

  • The element focused after open is inside the dialog
  • Repeated Tab never moves focus outside the dialog while it is open
  • Shift+Tab wraps correctly in the reverse direction
  • Disabled controls, hidden content, and inert background content are skipped

A trap that only works for forward tabbing is incomplete. You need to check both directions, because reverse traversal often exposes bad sentinel logic.

2) Escape key modal behavior

Pressing Escape should close the topmost active dialog, not a parent page overlay or browser-level feature, and not silently do nothing.

What to assert:

  • Escape closes the modal when focus is inside it
  • Escape does not close the wrong overlay if a nested popover or dialog is open
  • Focus is not stranded on a removed element after close
  • If the dialog is intentionally non-dismissable, that choice is documented and tested as an exception, not a bug

3) Return focus after modal close

Closing a dialog should restore focus to the control that opened it, or to a clearly defined fallback when the trigger no longer exists.

What to assert:

  • Focus returns to the opener after close
  • If the opener disappears because of a route change or state update, focus moves to a sensible replacement, such as a heading or nearby stable control
  • The page does not reset focus to <body> unless that is an explicit, tested decision

This is the step teams miss most often when the modal closes as part of a state transition. The dialog disappears, the DOM changes, and keyboard users lose their place.

A practical test model for browser automation

You do not need to test every tab stop in the dialog with exhaustive brute force. What you need is a small set of assertions that prove the keyboard contract is intact.

A useful model is:

  1. Open the dialog by keyboard or click
  2. Assert focus moved into the dialog
  3. Tab through the visible focusables until the loop wraps
  4. Press Escape and assert the dialog closes
  5. Assert focus returns to the opener or fallback

Playwright example

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: 'Edit profile' });
  await openButton.click();

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

  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await expect(page.locator('body')).not.toHaveAttribute('data-focus-outside-dialog', 'true');

  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(openButton).toBeFocused();
});

This example is intentionally small. In a real suite, you would make the focus assertions more explicit by checking document.activeElement or by querying the focused element inside the dialog after each key press.

A stronger pattern is to collect the focus order from the dialog and verify that every focused element belongs to the dialog container.

async function focusedSelector(page: any) {
  return page.evaluate(() => {
    const el = document.activeElement as HTMLElement | null;
    return el ? `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}` : null;
  });
}

What to assert about semantics, not just behavior

Keyboard behavior alone is not enough if the dialog lacks correct semantics.

Check for:

  • role="dialog" or role="alertdialog"
  • An accessible name from aria-labelledby, aria-label, or equivalent
  • aria-modal="true" when your implementation uses it correctly
  • A visible heading that matches the accessible name when possible

Do not assume aria-modal="true" fixes focus management. It describes modality to assistive technologies, but it does not trap focus by itself. The focus trap still has to be implemented and tested.

A good semantic test is usually a single assertion against the accessible role and name, then one or two keyboard checks. If your modal is exposed as a generic div, screen reader behavior becomes much less predictable.

Manual keyboard checks you should keep

Automation is good at repeatable contract checks. It is weaker at judging whether the interaction feels correct to a keyboard user across real browser behavior.

Keep manual checks for these cases:

  • Tabbing into the modal from a real keyboard, not just programmatic focus
  • Verifying the first focused control is the right one for the workflow
  • Checking that screen reader announcement order makes sense
  • Confirming focus restoration still feels correct when the opener is disabled, removed, or hidden after submit
  • Confirming nested dialogs or drawers behave intentionally, not accidentally

If your modal contains a destructive action or a form with validation, manual checks are especially useful for deciding whether focus should land on the close button, the primary action, or the first invalid field.

Common false positives in automation

Portal rendering

Many frameworks render modals into a portal attached near the end of document.body. That is fine, but it can confuse tests that assume the dialog is a DOM child of the trigger area.

Avoid assertions that depend on DOM proximity. Query by role and accessible name instead. That is more stable and closer to how assistive technology perceives the dialog.

Nested overlays

A date picker inside a modal, or a confirm dialog opened from a drawer, creates stacked focus scopes. In these cases, Escape should close the topmost layer first, then restore focus to the control inside the parent layer, not jump back to the original page trigger.

Your test should explicitly decide which layer owns Escape. If the behavior is not defined, the test will be flaky even when the implementation is correct for a different product decision.

SPA route changes

When a modal submit action changes the route, the trigger that opened the dialog may no longer exist.

This is where many return-focus tests fail for the wrong reason. If the source element is removed, focus restoration must target a stable replacement. Typical fallbacks include:

  • The page heading after navigation
  • A success message container with tabindex="-1"
  • A new primary action on the destination screen

Do not assert focus returns to the original opener if the opener no longer exists. Assert the intended fallback instead.

A compact decision table for test coverage

Concern Automation can verify Manual check still needed
Focus trap Focus stays inside the dialog while open Whether the first control is the best starting point
Escape key modal behavior Escape closes the topmost active dialog Whether close behavior is discoverable and expected
Return focus after modal close Focus returns to opener or fallback Whether the return target feels natural in the workflow
Accessible name and role Role and name are exposed correctly Screen reader announcement quality
Nested overlays Topmost layer closes first Whether the layering model matches product intent

A resilient test pattern for complex components

When modals live inside portals or nested overlays, test the public contract, not implementation details.

That means:

  • Use role-based locators, not CSS selectors tied to portal containers
  • Check document.activeElement after each key action
  • Prefer stable names and labels over internal class names
  • Reset the page state between tests so a prior modal does not leave stale focus state behind

If your component library exposes a dialog container element, you can still inspect it directly, but your test should not depend on a specific portal root or React subtree structure.

Example of a focused assertion helper

async function expectFocusInside(page: any, dialogName: string) {
  const active = await page.evaluate(() => {
    const el = document.activeElement as HTMLElement | null;
    return el?.closest('[role="dialog"], [role="alertdialog"]') !== null;
  });
  expect(active).toBeTruthy();
}

This kind of helper keeps the test readable and makes failures easier to diagnose than a large chain of raw keyboard events.

What to test in CI versus what to leave for review

In CI, verify the contract that is most likely to regress:

  • Dialog opens and receives focus
  • Focus remains trapped
  • Escape closes the dialog
  • Focus returns correctly
  • The accessible role and name are present

Leave these for lighter manual review or focused accessibility sessions:

  • Announcement quality with a screen reader
  • Whether the initial focus target matches the user’s task
  • Whether the modal should be dismissable at all in a specific workflow
  • Whether nested overlay behavior is understandable

That split keeps the automated suite small enough to maintain and still catches the regressions that break keyboard navigation.

A simple failure triage checklist

When a modal test fails, ask these questions in order:

  1. Did focus move into the dialog at open?
  2. Does the dialog still exist when the key event fires?
  3. Is a nested overlay intercepting Escape?
  4. Did the trigger disappear before focus restoration?
  5. Are you querying by role and name, or by a brittle selector?
  6. Is the dialog rendered in a portal, and did the test accidentally assume DOM nesting?

This order helps separate accessibility bugs from test bugs. A surprising number of failures are caused by stale references or unscoped locators, not by the modal logic itself.

Bottom line

To test accessible modal dialogs well, focus on the contract keyboard users depend on: focus enters the dialog, stays trapped there, Escape closes the topmost layer, and focus returns to a sensible target after close. Browser automation can prove those behaviors reliably if you assert against role, name, and active element state instead of DOM shape. Manual keyboard checks still matter for announcement quality and workflow judgment, especially in nested overlays and route-changing dialogs.

If your suite catches those three failure points consistently, it is doing the part of modal accessibility testing that actually prevents broken navigation.

FAQ

How do I test focus trap without checking every tab stop?

Check that focus starts inside the dialog, that repeated Tab and Shift+Tab never escape it, and that the focus order loops back to the beginning or end as designed.

Should every modal close with Escape?

No. Some destructive or blocking flows intentionally prevent Escape. If you choose that behavior, document it and test it explicitly so it is not mistaken for a bug.

What is the best selector for modal tests?

Use accessible role and name queries first, such as getByRole('dialog', { name: ... }). They are more stable than CSS selectors and better reflect how assistive technology finds the dialog.

What if the trigger disappears after the modal closes?

Do not assert that focus returns to a removed element. Test the fallback target you intentionally chose, such as a page heading or success message.

Is aria-modal="true" enough for an accessible modal?

No. It can help convey modality, but it does not implement focus trapping, Escape handling, or focus restoration. Those behaviors still need code and tests.

Do portals break accessible modal testing?

No, but they do make DOM-tree assumptions unreliable. Query the dialog by role and name, then assert focus behavior through the browser’s active element state.