Accessible Modal Dialog with React Hooks
A reusable, accessible modal dialog component using React hooks for managing open/close state.
Code
/**
* Accessible Modal Dialog (React Hooks)
* Reusable modal that traps focus, restores focus on close, blocks background scroll, and closes on ESC/overlay.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
const FOCUSABLE = 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
const Modal = ({ isOpen, onClose, title, children, initialFocusRef }) => {
const overlayRef = useRef(null);
const contentRef = useRef(null);
const lastActiveRef = useRef(null);
const close = useCallback(() => onClose?.(), [onClose]);
useEffect(() => {
if (!isOpen) return;
// Prevent background scroll while open; restore on cleanup.
const { overflow } = document.body.style;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = overflow || 'unset';
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
// Save/restore focus for accessibility.
lastActiveRef.current = document.activeElement;
// Focus initial element (preferred ref, otherwise first focusable, otherwise dialog itself).
const target =
initialFocusRef?.current ||
contentRef.current?.querySelector(FOCUSABLE) ||
contentRef.current;
// rAF avoids focusing before paint in some browsers.
const id = requestAnimationFrame(() => target?.focus?.());
const onKeyDown = (e) => {
if (e.key === 'Escape') close();
if (e.key !== 'Tab') return;
// Trap focus within the dialog.
const nodes = Array.from(contentRef.current?.querySelectorAll(FOCUSABLE) || []);
if (!nodes.length) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
cancelAnimationFrame(id);
document.removeEventListener('keydown', onKeyDown);
lastActiveRef.current?.focus?.();
};
}, [isOpen, close, initialFocusRef]);
if (!isOpen) return null;
return (
<div
ref={overlayRef}
className="modal-overlay"
role="presentation"
onMouseDown={(e) => e.target === overlayRef.current && close()} // Only close when clicking backdrop.
>
<div
ref={contentRef}
className="modal-content"
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : undefined}
tabIndex={-1}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="modal-header">
{title && <div className="modal-title">{title}</div>}
<button type="button" className="close-button" onClick={close} aria-label="Close dialog">
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
};
Modal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.node,
children: PropTypes.node.isRequired,
initialFocusRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
};
Modal.defaultProps = {
title: null,
initialFocusRef: undefined,
};
// Example usage: render any interactive content while preventing background interactions.
const ExampleUsage = () => {
const [isOpen, setIsOpen] = useState(false);
const openButtonRef = useRef(null);
return (
<div>
<button ref={openButtonRef} type="button" onClick={() => setIsOpen(true)}>
Open Modal
</button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Modal Title"
initialFocusRef={openButtonRef} // Swap for a ref to an input inside the modal if desired.
>
<p>This is an accessible modal dialog.</p>
<button type="button" onClick={() => setIsOpen(false)}>
Confirm
</button>
</Modal>
</div>
);
};
export default ExampleUsage;