Testing Focus Management in Modals

A modal dialog with broken focus management is the accessibility bug that a sighted mouse user will never notice and a keyboard user cannot get past. When a dialog opens, focus must move into it; while it is open, Tab and Shift+Tab must cycle within it rather than escaping to the page behind; on Escape it must close; and on close, focus must return to the element that opened it. This guide shows how to test all four properties with Vitest, React Testing Library, and @testing-library/user-event, asserting on document.activeElement throughout. It is for developers building or auditing dialogs, drawers, and popovers who want the focus contract enforced by tests instead of rediscovered during an accessibility review.

Root Cause Analysis

Focus management is the one accessibility concern where the correct behavior is entirely about where focus is, and where nothing in the visual rendering reveals a defect. A mouse user clicks a button, the dialog appears, they click inside it, they click a background element to dismiss — at no point does the position of the keyboard focus ring matter to them. For a keyboard or screen-reader user, focus position is the only thing that matters: if focus stays on the trigger behind the now-open dialog, they are tabbing through hidden page content they cannot see; if Tab escapes the dialog, they lose their place entirely; if focus is not restored on close, they are dumped at the top of the document. These are total blockers, and because they are invisible to visual testing they routinely ship.

The four sub-behaviors have distinct failure causes. Initial focus fails when the dialog renders but no effect moves focus into it, leaving activeElement on the trigger. Focus trapping fails when the implementation does not intercept Tab at the boundaries, so focus walks out the top or bottom of the dialog into the background. Dismissal fails when no key handler listens for Escape. Restoration fails when the component does not remember the previously focused element and refocus it on unmount. Each is a small, deterministic piece of logic operating on document.activeElement, which means each is precisely assertable in jsdom — you drive the dialog through its lifecycle with user-event and check where focus sits at every transition.

The focus lifecycle of a modal dialog Focus starts on the trigger, moves into the dialog on open, is trapped while open, and returns to the trigger when the dialog closes on Escape. Focus lifecycle: open to close Trigger focus starts here Dialog (open) focus trapped inside Tab cycles here Trigger focus restored open Escape
The four assertions map onto four transitions: focus in on open, trapped while open, closed on Escape, restored to the trigger.

Reproducible Setup

Use the Testing Library plus user-event stack from the rest of this area. The component is a controlled dialog that implements the full contract so the tests have something correct to lock in.

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// ConfirmDialog.tsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

export function ConfirmDialog({
  open,
  onClose,
  onConfirm,
}: {
  open: boolean;
  onClose: () => void;
  onConfirm: () => void;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previouslyFocused = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    previouslyFocused.current = document.activeElement as HTMLElement;
    dialogRef.current?.querySelector<HTMLElement>('button')?.focus();
    return () => previouslyFocused.current?.focus(); // restore on close
  }, [open]);

  if (!open) return null;

  return createPortal(
    <div
      role="dialog"
      aria-modal="true"
      aria-label="Confirm deletion"
      ref={dialogRef}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
        if (e.key === 'Tab') {
          const focusables = dialogRef.current!.querySelectorAll<HTMLElement>('button');
          const first = focusables[0];
          const last = focusables[focusables.length - 1];
          if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
          } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
          }
        }
      }}
    >
      <h2>Delete this item?</h2>
      <button onClick={onConfirm}>Confirm</button>
      <button onClick={onClose}>Cancel</button>
    </div>,
    document.body,
  );
}

Implementation

Step 1 — Assert focus moves into the dialog on open. Render a small harness whose trigger toggles the dialog, click the trigger, then assert that focus has landed on the first focusable control inside the dialog rather than remaining on the trigger.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { ConfirmDialog } from './ConfirmDialog';

function Harness() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Delete</button>
      <ConfirmDialog open={open} onClose={() => setOpen(false)} onConfirm={() => setOpen(false)} />
    </>
  );
}

test('moves focus into the dialog on open', async () => {
  const user = userEvent.setup();
  render(<Harness />);

  await user.click(screen.getByRole('button', { name: /^delete$/i }));
  expect(screen.getByRole('button', { name: /confirm/i })).toHaveFocus();
});

Step 2 — Assert the dialog exposes the modal role. A dialog must announce itself as a modal so assistive technology knows the background is inert. Query it by its dialog role and accessible name, and confirm aria-modal.

test('exposes a labelled modal dialog', async () => {
  const user = userEvent.setup();
  render(<Harness />);

  await user.click(screen.getByRole('button', { name: /^delete$/i }));
  const dialog = screen.getByRole('dialog', { name: /confirm deletion/i });
  expect(dialog).toHaveAttribute('aria-modal', 'true');
});

Step 3 — Assert Tab and Shift+Tab stay inside. Tab from the last control must wrap to the first, and Shift+Tab from the first must wrap to the last. These two assertions are the focus trap.

test('traps Tab and Shift+Tab within the dialog', async () => {
  const user = userEvent.setup();
  render(<Harness />);
  await user.click(screen.getByRole('button', { name: /^delete$/i }));

  const confirm = screen.getByRole('button', { name: /confirm/i });
  const cancel = screen.getByRole('button', { name: /cancel/i });

  expect(confirm).toHaveFocus(); // initial
  await user.tab();
  expect(cancel).toHaveFocus();
  await user.tab(); // wraps forward
  expect(confirm).toHaveFocus();
  await user.tab({ shift: true }); // wraps backward
  expect(cancel).toHaveFocus();
});
Tab wrapping inside a focus-trapped dialog Tab moves from the first to the last control and wraps back to the first, while Shift+Tab reverses it, so focus never leaves the dialog. Focus wraps at the dialog boundary dialog (aria-modal) Confirm (first) Cancel (last) Tab wraps Tab from last wraps to first; Shift+Tab from first wraps to last
The trap test walks Tab past the last control and asserts it wraps to the first rather than escaping.

Step 4 — Assert Escape closes the dialog. Press Escape and confirm the dialog leaves the document. Query with queryByRole so the absence assertion is expressed correctly.

test('Escape closes the dialog', async () => {
  const user = userEvent.setup();
  render(<Harness />);
  await user.click(screen.getByRole('button', { name: /^delete$/i }));

  await user.keyboard('{Escape}');
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

Step 5 — Assert focus returns to the trigger on close. This is the most-forgotten property. After closing the dialog by any means, the element that opened it must regain focus so the keyboard user resumes where they left off.

test('restores focus to the trigger after closing', async () => {
  const user = userEvent.setup();
  render(<Harness />);
  const trigger = screen.getByRole('button', { name: /^delete$/i });

  await user.click(trigger);
  await user.keyboard('{Escape}');
  expect(trigger).toHaveFocus();
});

Verification

Confirm the suite guards the real contract by disabling one property at a time and watching exactly one test go red. Comment out the cleanup return in the useEffect — the return () => previouslyFocused.current?.focus() line — and only the restoration test should fail, with activeElement left on document.body instead of the trigger. Remove the if (e.key === 'Escape') branch and only the Escape test fails. Delete the Tab-handling block and only the trap test fails, because focus escapes to the background. This one-defect-one-failure mapping is the proof that each property has its own guarding assertion rather than being incidentally covered.

npx vitest run ConfirmDialog.test.tsx

Because the dialog renders through a portal to document.body, verify your queries use screen rather than a container-scoped query; a scoped query would miss the portaled dialog entirely and give a false negative. Pair these focus tests with an axe scan of document.body so structural issues in the dialog markup are caught alongside the focus behavior.

It helps to keep the four properties and their guarding assertions in view as a single map, because a dialog spec is only complete when all four are present. Initial focus is proved by asserting toHaveFocus on the first control right after open; trapping is proved by tabbing past the boundary and asserting the wrap; dismissal is proved by pressing Escape and asserting the dialog is gone with queryByRole; and restoration is proved by asserting the trigger regains focus after close. Miss any one and a real keyboard user hits a wall that no other test in the suite would catch, so treat the map below as a checklist you run down for every overlay you build.

The four dialog properties mapped to their guarding assertions Initial focus, focus trapping, Escape dismissal, and focus restoration each map to a specific assertion on the active element or the dialog's presence. Four properties, four assertions Initial focus on open Focus trap while open Escape closes dismissal Restoration on close toHaveFocus first / wrap / trigger queryByRole dialog absent
A complete dialog spec asserts all four properties; missing any one leaves a keyboard-only wall uncaught.

Troubleshooting

Symptom: the initial-focus test fails with focus on document.body. Diagnosis: the effect that should move focus into the dialog runs before the portal content is in the DOM, or the query for the first focusable finds nothing. Fix: move focus in a layout effect after the dialog mounts, target a known focusable element explicitly, and if the dialog fetches its contents, wait for them with findByRole before asserting focus.

Symptom: the trap test passes but real users still tab out of the dialog. Diagnosis: jsdom does not enforce true modality, so a hand-rolled trap that only handles the two boundary buttons can pass in tests while missing focusable elements the test did not include, such as a close icon or a link. Fix: compute the focusable set dynamically from a complete selector rather than assuming two buttons, and consider a vetted focus-trap library; then add the missing controls to the test so it reflects the real DOM.

Symptom: the restoration test is flaky under fake timers. Diagnosis: the restore runs inside an effect cleanup that has not flushed when you assert, especially if a closing animation is timed. Fix: await the user-event call that triggers the close, advance any fake timers the animation uses, and keep the act() boundary clean so the cleanup effect has committed before the focus assertion.

FAQ

Can jsdom fully verify a focus trap?

It can verify the logic of your trap but not true browser modality. jsdom runs your keydown handlers and updates document.activeElement, so a test proves that Tab at a boundary wraps the way your code intends. What it cannot prove is that the browser’s own tab order and inert/aria-modal semantics keep focus out of the background, because jsdom does not implement that enforcement. The practical consequence is to make your trap operate on a dynamically computed focusable set and to add every real focusable control to the test, then rely on a real-browser layer for final confirmation that modality holds.

Should I test with a focus-trap library or hand-rolled logic?

For anything beyond the simplest two-button confirm, prefer a vetted focus-trap library, because correct trapping has to handle a moving target: dynamically added controls, disabled elements, nested focusable regions, and the boundary wrap in both directions. Your tests stay the same either way — they assert the observable focus behavior, not the implementation — so you can start hand-rolled and swap in a library later without rewriting the suite. The tests are what protect you during that swap, since they will immediately reveal any behavior the library handles differently.

Where should focus go when the dialog has no interactive controls?

When a dialog is purely informational with no buttons or fields, move initial focus to the dialog container itself by giving it tabindex="-1" and focusing it on open, so a screen reader announces the dialog and the user is inside it rather than stranded on the background. Test this by asserting the dialog element has focus after open. On close, restore focus to the trigger exactly as with an interactive dialog; the restoration contract does not change just because the dialog had nothing to tab through.

Do these tests replace end-to-end dialog testing?

They cover the focus contract thoroughly and cheaply, which is the part most likely to regress, but they do not replace a browser-level check of true modality and visible focus indicators. jsdom cannot confirm that background content is genuinely inert to pointer and assistive-technology navigation, nor that the focus ring is visible, because those depend on real rendering. Keep this fast suite as the per-pull-request guard on focus logic, and run a small number of real-browser checks for the rendering-dependent guarantees, following the same layering the rest of the component testing area uses.