Skip to main content
CodeRocket
Accessibilityhighcomponents

Make modal dialogs keyboard accessible

rule · modal-accessibility

Accessible modals require proper ARIA attributes, focus management, and keyboard interaction.

Code Example

HTML
<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

TSX
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

TSX
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

RequirementImplementation
ARIA rolerole="dialog"
Modal flagaria-modal="true"
Labelaria-labelledby pointing to title
Focus trapTab cycles within modal only
Escape keyCloses the modal
Return focusFocus returns to trigger on close
Background scrollDisabled 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