Accessible Modal Dialog
Machine-coding brief for a modal dialog — focus trap, Escape, scroll lock, restore focus, and ARIA dialog pattern.
- machine-coding
- react
- a11y
- focus
- interview
Problem statement
Build an accessible modal dialog. Opening it should focus inside, trap Tab focus, close on Escape and optional backdrop click, restore focus to the opener, and expose the correct ARIA semantics. This is a favorite machine-coding / UI-craft round because most candidates ship a “centered div” and fail keyboard users.
Requirements
Must have
- Open / close controlled or uncontrolled API
- Overlay (backdrop) + dialog panel
- Initial focus moves into the dialog (first focusable or labeled container)
- Focus trap while open (Tab / Shift+Tab cycle)
- Escape closes
- Focus returns to the element that opened the dialog
role="dialog"+aria-modal="true"+ labelled by title- Body scroll lock while open
- Close button with an accessible name
Should have
- Optional confirm / cancel actions
- Disable backdrop close for destructive flows (
closeOnBackdropClick) - Portal to
document.body
Nice to have
- Enter/exit animation with
prefers-reduced-motion - Nested dialog stacking
- Native
<dialog>+showModal()variant discussion
Planning (5 minutes out loud)
- API —
open,onOpenChange,title,children - Focus — save previously focused node; focus first tabbable on open
- Trap — keydown on Tab at edges
- Portal — render above app stacking context
- MVP — open/close + Escape + focus restore; then trap + scroll lock
Architecture
Modal (portal)
├── Backdrop
└── Dialog panel
├── Header (title + close)
├── Body
└── Footer (actions)
Component API
type ModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: React.ReactNode;
closeOnBackdropClick?: boolean; // default true
initialFocusRef?: React.RefObject<HTMLElement | null>;
};
Implementation sketch
Portal + scroll lock + Escape
function Modal({
open,
onOpenChange,
title,
children,
closeOnBackdropClick = true,
initialFocusRef,
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const toFocus =
initialFocusRef?.current ??
panelRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) ??
panelRef.current;
toFocus?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
onOpenChange(false);
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKeyDown);
previouslyFocused.current?.focus?.();
};
}, [open, onOpenChange, initialFocusRef]);
useFocusTrap(panelRef, open);
if (!open) return null;
return createPortal(
<div className="modal-root">
<div
className="modal-backdrop"
onClick={() => {
if (closeOnBackdropClick) onOpenChange(false);
}}
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="modal-panel"
onClick={(e) => e.stopPropagation()}
>
<header>
<h2 id={titleId}>{title}</h2>
<button type="button" aria-label="Close dialog" onClick={() => onOpenChange(false)}>
×
</button>
</header>
<div>{children}</div>
</div>
</div>,
document.body
);
}
Focus trap (Tab cycle)
function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, enabled: boolean) {
useEffect(() => {
if (!enabled) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const root = containerRef.current;
if (!root) return;
const focusables = root.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const list = Array.from(focusables).filter((el) => !el.hasAttribute("disabled"));
if (list.length === 0) {
e.preventDefault();
root.focus();
return;
}
const first = list[0];
const last = list[list.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [containerRef, enabled]);
}
Give the panel tabIndex={-1} if it may receive focus when there are no tabbables.
Usage
function Example() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Delete item
</button>
<Modal open={open} onOpenChange={setOpen} title="Delete item?" closeOnBackdropClick={false}>
<p>This cannot be undone.</p>
<button type="button" onClick={() => setOpen(false)}>
Cancel
</button>
<button type="button">Delete</button>
</Modal>
</>
);
}
Accessibility notes
| Rule | Implementation |
|---|---|
| Name | aria-labelledby → visible title |
| Modal | aria-modal="true" (hides background from AT in supporting browsers) |
| Focus | Enter: first control; Exit: restore opener |
| Keyboard | Escape, Tab trap |
| Backdrop | Optional click; never the only close method |
| Motion | Respect prefers-reduced-motion |
Prefer referencing APG Dialog (Modal) over inventing roles.
Native <dialog>: showModal() gives focus trap and top-layer for free in modern browsers. In interviews, implementing the pattern manually shows understanding; mentioning native is a plus.
Related: Accessible modals, Focus management, Keyboard checklist.
Performance notes
- Portal once; don’t remount heavy children if you only toggle CSS — but for interviews, conditional render is fine.
- Avoid
useEffectfocus loops: depend onopen, not on every render. - Scroll lock: set
overflow: hiddenonbody; restore previous value exactly.
Interview expectations
| Signal | What good looks like |
|---|---|
| A11y first | Roles + labels without prompting |
| Focus | Save / move / restore |
| Trap | Tab cycles; Escape works |
| API | Controlled open / onOpenChange |
| Edge | No focusables; backdrop policy |
| Time | Working modal in ~30 min, trap polished after |
Extensions they may ask live
- Nested modals (stack of restore targets)
- Drawers / non-modal popovers (
aria-modalfalse, different dismiss rules) - Form dialog with validation error focus
- Animation + remove from tab order when closed
Related
A modal that only works with a mouse is incomplete. Ship focus, Escape, and restore as part of MVP — not “if time.”