Make modal dialogs keyboard accessible
Modal dialogs are accessible with proper focus trapping, ARIA attributes, and keyboard dismissal.
- Use role='dialog' and aria-modal='true' on the modal container
- Trap focus inside the modal—Tab should cycle within modal only
- Close on Escape key press and return focus to trigger element
- Add aria-labelledby pointing to the modal title
Rule Details
Accessible modals require proper ARIA attributes, focus management, and keyboard interaction.
Code Example
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Confirm Action</h2>
<p id="modal-description">Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>Why It Matters
Without proper focus trapping and keyboard handling, modal dialogs are invisible traps for keyboard users—they can't navigate, can't escape, and can't complete tasks.
Complete Accessible Modal
import { useEffect, useRef, useCallback } from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLElement | null>(null)
// Store trigger and focus modal on open
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement
modalRef.current?.focus()
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
triggerRef.current?.focus()
}
}, [isOpen])
// Handle keyboard events
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusableElements?.length) return
const first = focusableElements[0] as HTMLElement
const last = focusableElements[focusableElements.length - 1] as HTMLElement
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleKeyDown])
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
>
<h2 id="modal-title">{title}</h2>
{children}
<button
onClick={onClose}
aria-label="Close dialog"
className="close-button"
>
×
</button>
</div>
</>
)
}Using Native Dialog Element
function NativeDialog({ isOpen, onClose, title, children }) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
dialog.showModal() // Handles focus trapping automatically
} else {
dialog.close()
}
}, [isOpen])
return (
<dialog
ref={dialogRef}
onClose={onClose}
aria-labelledby="dialog-title"
>
<h2 id="dialog-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</dialog>
)
}Accessibility Checklist
| Requirement | Implementation |
|---|---|
| ARIA role | role="dialog" |
| Modal flag | aria-modal="true" |
| Label | aria-labelledby pointing to title |
| Focus trap | Tab cycles within modal only |
| Escape key | Closes the modal |
| Return focus | Focus returns to trigger on close |
| Background scroll | Disabled while modal is open |
Exceptions
- Evaluate the rendered experience before treating a static-code smell as a blocker; interaction timing, browser behavior, and assistive technology output often determine severity.
- Not every secondary accessibility issue deserves equal weight; prioritize the issue that most directly blocks perception, operation, or understanding.
- Avoid adding redundant markup or ARIA solely to satisfy a rule when a simpler semantic implementation would eliminate the issue entirely.
Standards
- Align the implementation with W3C WAI: WCAG Overview and verify the rendered experience, not only the source code.
- Align the implementation with MDN: Accessibility and verify the rendered experience, not only the source code.
Verification
Automated Checks
- Use browser accessibility tooling, axe, Lighthouse, or equivalent automated checks against a representative rendered state.
Manual Checks
- Open modal with keyboard (Enter/Space on trigger)
- Verify focus moves inside modal
- Tab through all elements—focus should not escape
- Press Escape—modal should close
- Verify focus returns to the element that opened the modal
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Verify modals have proper ARIA roles, focus trapping, keyboard dismissal (Escape), and return focus on close.
Fix
Auto-fix issues
Implement accessible modals using role='dialog', aria-modal='true', focus trapping, and proper keyboard handling.
Explain
Learn more
Explain the accessibility requirements for modal dialogs including focus management, ARIA attributes, and keyboard interaction.
Review
Code review
Review the rendered markup and interactive states that affect Make modal dialogs keyboard accessible. Flag exact elements, roles, labels, focus behavior, or keyboard interactions that violate the rule, and note how to verify the fix with browser accessibility tooling or assistive tech.