import { useEffect, useRef, ReactNode } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
closeOnBackdrop?: boolean
}
export function Modal({ isOpen, onClose, title, children, closeOnBackdrop = true }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
// Focus first focusable element
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
// Prevent body scroll
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
previousFocusRef.current?.focus()
}
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
onClick={(e) => {
if (closeOnBackdrop && e.target === e.currentTarget) {
onClose()
}
}}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between p-6 border-b">
<h2 id="modal-title" className="text-xl font-bold">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
aria-label="Close dialog"
>
<i className="fas fa-times text-xl" />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>,
document.body
)
}
Accessible modals require focus management, keyboard navigation, and ARIA attributes. I build a reusable Modal component that traps focus inside when open, moves focus to the first interactive element, and returns it to the trigger on close. The Escape key closes the modal, and clicking the backdrop optionally dismisses it. ARIA attributes like role='dialog', aria-modal='true', and aria-labelledby provide semantic meaning for screen readers. I prevent body scroll when the modal is open and use React Portal to render modals at the document root, avoiding z-index conflicts. This pattern ensures modals work for all users regardless of input method.