Implement accessible modal dialogs and overlays — focus trapping, scroll locking, ARIA roles, keyboard dismissal, and return focus patterns that work with screen readers.
Implement accessible modal dialogs and overlays — focus trapping, scroll locking, ARIA roles, keyboard dismissal, and return focus patterns that work with screen readers.
An accessible modal requires a specific set of ARIA attributes and HTML structure:
1<!-- The modal overlay -->
2<div
3 class="modal-backdrop"
4 role="presentation"
5 aria-hidden="true"
6 onclick="closeModal()"
7></div>
8
9<!-- The dialog itself -->
10<div
11 role="dialog"
12 aria-modal="true"
13 aria-labelledby="dialog-title"
14 aria-describedby="dialog-description"
15 id="confirm-dialog"
16 tabindex="-1" /* Allows programmatic focus without entering Tab sequence */
17>
18 <h2 id="dialog-title">Confirm account deletion</h2>
19 <p id="dialog-description">
20 This action cannot be undone. Your account and all associated
21 data will be permanently deleted.
22 </p>
23
24 <div class="dialog-actions">
25 <button type="button" onclick="closeModal()">Cancel</button>
26 <button type="button" class="btn-destructive" onclick="deleteAccount()">
27 Delete account
28 </button>
29 </div>
30
31 <!-- Close button in dialog header -->
32 <button
33 type="button"
34 aria-label="Close dialog"
35 onclick="closeModal()"
36 class="dialog-close"
37 >
38 <svg aria-hidden="true"><!-- X icon --></svg>
39 </button>
40</div>aria-modal='true' tells screen readers that content behind the dialog is inert — they should not browse to it. Without this, VoiceOver users can Tab out of the modal into the background content. aria-labelledby references the dialog title. aria-describedby references the description, announced when focus enters the dialog.
A focus trap confines keyboard focus within the modal. Without it, Tab moves focus into the background page, leaving screen reader users in the modal but keyboard users outside it:
1// useFocusTrap.ts — the complete implementation
2import { useEffect, useRef } from 'react';
3
4const FOCUSABLE_SELECTORS = [
5 'a[href]',
6 'button:not([disabled])',
7 'input:not([disabled])',
8 'select:not([disabled])',
9 'textarea:not([disabled])',
10 '[tabindex]:not([tabindex="-1"])',
11 '[contenteditable]',
12].join(', ');
13
14export function useFocusTrap(isActive: boolean) {
15 const containerRef = useRef<HTMLDivElement>(null);
16
17 useEffect(() => {
18 if (!isActive || !containerRef.current) return;
19
20 const container = containerRef.current;
21 const focusables = Array.from(
22 container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
23 );
24 const first = focusables[0];
25 const last = focusables[focusables.length - 1];
26
27 // Move focus into the dialog on mount
28 first?.focus();
29
30 function trapFocus(e: KeyboardEvent) {
31 if (e.key !== 'Tab') return;
32
33 if (e.shiftKey) {
34 // Shift+Tab: wrap from first to last
35 if (document.activeElement === first) {
36 e.preventDefault();
37 last?.focus();
38 }
39 } else {
40 // Tab: wrap from last to first
41 if (document.activeElement === last) {
42 e.preventDefault();
43 first?.focus();
44 }
45 }
46 }
47
48 document.addEventListener('keydown', trapFocus);
49 return () => document.removeEventListener('keydown', trapFocus);
50 }, [isActive]);
51
52 return containerRef;
53}WCAG 2.1.2 (No Keyboard Trap, Level A) requires that focus be moveable away from any component via keyboard. For modals, the Escape key is the expected dismissal mechanism:
1function Dialog({ isOpen, onClose, children, title }: DialogProps) {
2 const containerRef = useFocusTrap(isOpen);
3 const triggerRef = useRef<HTMLElement | null>(null);
4
5 // Store trigger before opening
6 useEffect(() => {
7 if (isOpen) {
8 triggerRef.current = document.activeElement as HTMLElement;
9 }
10 }, [isOpen]);
11
12 // Escape key dismissal
13 useEffect(() => {
14 if (!isOpen) return;
15 const handleKeyDown = (e: KeyboardEvent) => {
16 if (e.key === 'Escape') {
17 e.preventDefault();
18 onClose();
19 }
20 };
21 document.addEventListener('keydown', handleKeyDown);
22 return () => document.removeEventListener('keydown', handleKeyDown);
23 }, [isOpen, onClose]);
24
25 // Return focus on close
26 useEffect(() => {
27 if (!isOpen && triggerRef.current) {
28 triggerRef.current.focus();
29 }
30 }, [isOpen]);
31
32 if (!isOpen) return null;
33
34 return createPortal(
35 <>
36 {/* Backdrop: click to dismiss, but not the sole dismissal method */}
37 <div
38 className="modal-backdrop"
39 onClick={onClose}
40 aria-hidden="true"
41 />
42 <div
43 ref={containerRef}
44 role="dialog"
45 aria-modal="true"
46 aria-labelledby="dialog-title"
47 className="modal-dialog"
48 >
49 <h2 id="dialog-title">{title}</h2>
50 {children}
51 </div>
52 </>,
53 document.body
54 );
55}When a modal opens, the background page should not scroll. The naive solution (overflow: hidden on body) causes a layout shift when the scrollbar disappears. The correct solution compensates for the scrollbar width:
1// useScrollLock.ts
2import { useEffect } from 'react';
3
4export function useScrollLock(isLocked: boolean) {
5 useEffect(() => {
6 if (!isLocked) return;
7
8 // Calculate scrollbar width to prevent layout shift
9 const scrollbarWidth =
10 window.innerWidth - document.documentElement.clientWidth;
11
12 const originalOverflow = document.body.style.overflow;
13 const originalPaddingRight = document.body.style.paddingRight;
14
15 document.body.style.overflow = 'hidden';
16 // Compensate for hidden scrollbar to prevent layout shift
17 document.body.style.paddingRight = `${scrollbarWidth}px`;
18
19 return () => {
20 document.body.style.overflow = originalOverflow;
21 document.body.style.paddingRight = originalPaddingRight;
22 };
23 }, [isLocked]);
24}
25
26// In Dialog component:
27useScrollLock(isOpen);For fixed-position headers that include a scrollbar width offset:
1/* Fixed header also gets the compensating padding */
2.fixed-header {
3 right: 0;
4}
5
6/* When dialog is open, pad the header too */
7[data-dialog-open] .fixed-header {
8 padding-right: var(--scrollbar-width);
9}Not all overlays should be modal dialogs. Use the right ARIA pattern for the context:
1<!-- Alert dialog: requires immediate user action, blocks everything -->
2<!-- Use for confirmation of destructive actions -->
3<div
4 role="alertdialog"
5 aria-modal="true"
6 aria-labelledby="alert-title"
7 aria-describedby="alert-desc"
8>
9 <h2 id="alert-title">Delete 52 files?</h2>
10 <p id="alert-desc">This cannot be undone.</p>
11 <button>Cancel</button>
12 <button class="btn-danger">Delete</button>
13</div>
14
15<!-- Drawer / side panel: not always modal -->
16<!-- If it does not block background content, do NOT use aria-modal -->
17<div role="complementary" aria-label="Filters panel">
18 <!-- User can Tab back to main content -->
19</div>
20
21<!-- Tooltip: not a dialog at all -->
22<div role="tooltip" id="tip-1">Full user name</div>
23<button aria-describedby="tip-1">ST</button>
24
25<!-- Popover (non-modal): use popover attribute (HTML spec 2024) -->
26<button popovertarget="my-popover">Options</button>
27<div popover id="my-popover" role="menu">
28 <button role="menuitem">Edit</button>
29 <button role="menuitem">Delete</button>
30</div>Overuse of aria-modal='true' on non-modal overlays (drawers, tooltips, popovers) traps screen reader users unnecessarily. Only use aria-modal for elements that truly prevent interaction with the background.
Open your application's most important modal dialog. Navigate it with keyboard only, testing: does Tab cycle within the modal? Does Escape close it? Does focus return to the trigger after closing? Fix any failures using the useFocusTrap and Escape key patterns from this lesson.
Your progress is saved in this browser, on this device only.