Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Make modal dialogs keyboard accessible

Modal dialogs are accessible with proper focus trapping, ARIA attributes, and keyboard dismissal.

Utilities
Quick take
Typical fix time 30 min
  • 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
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.

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

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

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.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

axe DevTools
deque.comTool
Modal Pattern – UX Patterns for Developers

Comprehensive UX pattern guide covering anatomy, accessibility, best practices, and implementation.

uxpatterns.devGuide

Rules that often go hand-in-hand with this one.

Ensure dialogs have an accessible name

Checks that dialog elements have accessible names to orient screen reader users.

Accessibility
Enable keyboard navigation for all elements

All interactive elements are accessible via keyboard with logical focus order and hidden elements excluded from tab sequence.

Accessibility
Create accessible tooltips

Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.

Accessibility
Make carousels accessible

Carousels and sliders are accessible with pause controls, keyboard navigation, and proper ARIA attributes.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385