Modal dialogs trap focus, restore focus when closed, and use role="dialog" with aria-label
Why it matters
Modal dialogs that do not trap keyboard focus allow Tab to move focus outside the modal into background content that is visually obscured, leaving keyboard users unable to see or interact with the focused element. Modals without role="dialog" are not announced as dialogs by screen readers, so users receive no cue that a blocking overlay has appeared. When focus fails to return to the trigger element on close, users lose their place in the page entirely. WCAG 2.2 SC 2.1.1 (Level A) and SC 4.1.2 (Level A) both apply; Section 508 2018 Refresh 502.3.2 requires that the full dialog lifecycle be accessible to AT. Broken modal focus is one of the most common Section 508 findings in government application audits.
Severity rationale
Medium because broken modal focus management disrupts keyboard and screen reader users at a critical interaction point, but it does not prevent access to content outside the modal.
Remediation
Implement focus trapping and restoration in src/components/Modal.tsx. Store a ref to the trigger element before opening, move focus to the first focusable element on open, and return focus on close:
const triggerRef = useRef<HTMLButtonElement>(null);
const firstFocusRef = useRef<HTMLButtonElement>(null);
const openModal = () => {
setIsOpen(true);
// Move focus after render
requestAnimationFrame(() => firstFocusRef.current?.focus());
};
const closeModal = () => {
setIsOpen(false);
triggerRef.current?.focus(); // Restore to trigger
};
{isOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Confirm deletion</h2>
<button ref={firstFocusRef} onClick={closeModal}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</div>
)}
Use a library like focus-trap-react for robust cross-browser focus trapping rather than hand-rolling the Tab key handler.
Detection
- ID:
modal-focus - Severity:
medium - What to look for: Count all relevant instances and enumerate each. Examine all modal or dialog elements. Check whether they have
role="dialog"and anaria-labeldescribing the dialog. Verify that Tab key navigation is trapped within the dialog (last focusable element tabs back to first). Verify that focus is restored to the triggering element when the dialog is closed. - Pass criteria: All modals have
role="dialog"andaria-label. At least 1 implementation must be verified. Focus is trapped within the modal while open (Tab loops within the modal). When closed, focus returns to the button or element that opened the modal. - Fail criteria: Modals lack
role="dialog"oraria-label, or focus is not trapped, or focus is not restored on close. - Skip (N/A) when: No modal dialogs exist.
- Detail on fail: Example:
"Confirmation modal in the delete workflow has no role='dialog' or aria-label. When Tab is pressed on the last button, focus moves to the page behind the modal instead of looping back to the first button. When the modal is closed, focus does not return to the delete button." - Remediation: Implement proper modal focus management:
const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const firstButtonRef = useRef(null); const openModal = () => { setIsOpen(true); setTimeout(() => firstButtonRef.current?.focus(), 0); }; const closeModal = () => { setIsOpen(false); triggerRef.current?.focus(); }; return ( <> <button ref={triggerRef} onClick={openModal}> Delete </button> {isOpen && ( <div role="dialog" aria-label="Confirm deletion" aria-modal="true"> <button ref={firstButtonRef}>Cancel</button> <button>Delete</button> </div> )} </> );
External references
- wcag:2.2 · 2.1.1 — Keyboard
- wcag:2.2 · 4.1.2 — Name, Role, Value
- section-508:2018-refresh · 502.3.2 — Modification of User Interface Elements — Name Role Value
Taxons
History
- 2026-04-18·v1.0.0·Initial import from gov-section-508·automated