# Modals trap focus and communicate their purpose

- **Pattern:** `ab-000054` (`accessibility-basics.semantic-structure-aria.modal-focus-trap`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000054
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000054)

## Why it matters

A modal dialog that does not trap focus allows Tab presses to reach background content — visually hidden behind the overlay — while screen reader users may never realize the modal is open at all. WCAG 2.2 SC 2.1.2 (No Keyboard Trap) is often misread as 'don't trap focus,' but the actual requirement is that users can both enter and exit a trap using standard keys (Escape closes the modal). SC 2.4.3 (Focus Order) and SC 4.1.2 (Name, Role, Value) require modals to be correctly marked up with `role="dialog"` and `aria-modal="true"`. Section 508 2018 Refresh 502.3.1 covers programmatic determinability of role. When `aria-modal` is absent, some screen readers ignore the visual overlay entirely and continue reading background content as if the modal weren't there, leading users to interact with controls they cannot see.

## Severity rationale

Medium because focus-trap failures create confusing and disorienting experiences for keyboard users without blocking all access to the application.

## Remediation

Use a battle-tested focus trap library (`focus-trap-react`, `@radix-ui/react-dialog`) rather than implementing from scratch. If implementing manually, ensure focus moves in, Tab cycles only within the modal, Escape closes it, and focus returns to the trigger on close.

```tsx
import { useEffect, useRef } from 'react';

export function Modal({ isOpen, onClose, title, children, triggerRef }) {
  const firstFocusRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isOpen) {
      // Move focus into modal immediately
      firstFocusRef.current?.focus();
    } else {
      // Return focus to trigger on close
      triggerRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleKey);
    return () => document.removeEventListener('keydown', handleKey);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">{title}</h2>
      {children}
      <button ref={firstFocusRef} onClick={onClose}>Close</button>
    </div>
  );
}
```

For most applications, reach for `@radix-ui/react-dialog` which handles all of this correctly by default.

## Detection

- **ID:** `modal-focus-trap`
- **Severity:** `medium`
- **What to look for:** Enumerate every relevant item. Check modal or dialog components. Focus should be trapped inside the modal (Tab cycles through modal controls only, not background content). When the modal closes, focus should return to the trigger element. Check whether the modal has proper ARIA attributes (`role="dialog"`, `aria-modal="true"`, `aria-labelledby`).
- **Pass criteria:** At least 1 of the following conditions is met. When a modal opens, focus moves inside the modal. Tab cycles only through modal controls. Background content is not focusable. When the modal closes, focus returns to the trigger element. Modal has `role="dialog"` and `aria-modal="true"`.
- **Fail criteria:** Focus is not trapped in the modal, or pressing Tab moves focus to background elements. Focus does not restore when the modal closes. Modal lacks proper ARIA attributes.
- **Skip (N/A) when:** The application has no modal or dialog components.
- **Detail on fail:** Describe the focus issue. Example: `"Modal component does not trap focus. Tabbing cycles to background elements. No aria-modal or role='dialog' attributes."`
- **Remediation:** Implement focus trap and proper ARIA for modals:

  ```tsx
  const ModalComponent = ({ isOpen, onClose, trigger }) => {
    const modalRef = useRef(null);
    const firstInteractiveRef = useRef(null);

    useEffect(() => {
      if (isOpen) {
        firstInteractiveRef.current?.focus();
        const handleKeyDown = (e) => {
          if (e.key === 'Escape') onClose();
        };
        document.addEventListener('keydown', handleKeyDown);
        return () => document.removeEventListener('keydown', handleKeyDown);
      }
    }, [isOpen]);

    return (
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal"
      >
        <h2 id="modal-title">Modal Title</h2>
        <button ref={firstInteractiveRef}>Action 1</button>
        <button>Action 2</button>
        <button onClick={onClose}>Close</button>
      </div>
    );
  };
  ```

---

## External references

- wcag 2.1.2
- wcag 4.1.2
- wcag 2.4.3
- section-508 502.3.1

Taxons: accessibility

HTML version: https://auditbuffet.com/patterns/ab-000054
