import { useEffect, useRef } from 'react';
export function Modal({ open, onClose, title, children }: any) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
useEffect(() => {
if (!open || !ref.current) return;
const el = ref.current;
const focusable = el.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = focusable[0];
first?.focus();
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const last = focusable[focusable.length - 1];
if (!last || !first) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
el.addEventListener('keydown', handler);
return () => el.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
return (
<div role="dialog" aria-modal="true" aria-label={title} className="overlay">
<div ref={ref} className="modal">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Modals are accessibility traps—literally. Without focus management, keyboard users can tab into the page behind the modal and get lost. I trap focus within the dialog while it’s open, restore focus to the trigger on close, and support Escape to dismiss. I also set aria-modal and label the dialog so screen readers announce it properly. I’m not aiming for perfection; I’m aiming for ‘doesn’t break basic accessibility expectations’. In practice, I’ll often use a well-tested library (like Radix), but the pattern is still worth understanding because you’ll eventually need a custom dialog for something.