```tsx
const FOCUSABLE = 'a[href],button,textarea,input,select,[tabindex]:not([tabindex="-1"])';
export function Modal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement;
const focusables = ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE);
focusables?.[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { onClose(); return; }
if (e.key !== "Tab") return;
const list = Array.from(ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);
if (list.length === 0) { e.preventDefault(); return; }
const idx = list.indexOf(document.activeElement as HTMLElement);
const next = e.shiftKey
? (idx <= 0 ? list.length - 1 : idx - 1)
: (idx >= list.length - 1 ? 0 : idx + 1);
e.preventDefault();
list[next].focus();
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div role="dialog" aria-modal="true" ref={ref}>{children}</div>,
document.body
);
}
```
Required a11y behaviors for modals:
- `role="dialog"` + `aria-modal="true"`.
- Focus moves into the modal on open; restores to the previously-focused element on close.
- Tab is trapped (the math you implemented).
- Escape closes.
- Backdrop click closes (optional, but standard).
- Render via portal so the DOM hierarchy doesn't trap focus inside an inert ancestor.
Modern alternative: the native `<dialog>` element handles focus trap, scroll lock, and inertness automatically. Use `dialog.showModal()`. Browser support is now broad.
The full reference solution in every supported language stays in the editor above - reveal it there once you have had a real attempt.