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.
Medium because focus-trap failures create confusing and disorienting experiences for keyboard users without blocking all access to the application.
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.
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.
ID: accessibility-basics.semantic-structure-aria.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:
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>
);
};