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.
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.
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.
gov-section-508.forms-interactive.modal-focusmediumrole="dialog" and an aria-label describing 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.role="dialog" and aria-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.role="dialog" or aria-label, or focus is not trapped, or focus is not restored on close."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."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>
)}
</>
);