Build a modal component (focus trap, ARIA)
An accessible modal uses role='dialog' aria-modal='true' with an accessible name, renders in a portal, traps Tab focus inside while open, moves focus in on open and restores it to the trigger on close, closes on Escape and overlay click, and makes background content inert. The native <dialog> element gives most of this for free.
A modal is the canonical accessibility interview question because it touches focus management, keyboard handling, ARIA, and DOM structure all at once.
What "accessible modal" means — the checklist
- Role & name —
role="dialog"(orrole="alertdialog"for confirmations),aria-modal="true", and an accessible name viaaria-labelledbypointing at the title. - Focus moves in — on open, focus goes to the first focusable element (or the dialog/close button).
- Focus is trapped — Tab and Shift+Tab cycle within the modal; you can't tab out to the background.
- Focus is restored — on close, focus returns to the element that opened the modal.
- Escape closes it.
- Background is inert — content behind the modal is not focusable or readable by screen readers (
inertattribute oraria-hidden). - Rendered in a portal — appended to
<body>so it isn't clipped byoverflowor trapped under a stacking context.
Hand-rolled React implementation
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
triggerRef.current = document.activeElement as HTMLElement;
const dialog = ref.current!;
const focusables = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
first?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "Tab") {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first?.focus();
}
}
}
dialog.addEventListener("keydown", onKeyDown);
return () => {
dialog.removeEventListener("keydown", onKeyDown);
triggerRef.current?.focus(); // restore focus on close
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()} // don't close on inner click
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}The native <dialog> element does most of this for free
const dialog = document.querySelector("dialog");
dialog.showModal(); // traps focus, makes background inert, adds ::backdrop, Escape closes
dialog.close(); // restores focus to the trigger automaticallyshowModal() gives you the focus trap, background inertness, Escape-to-close, and focus restoration natively. You still set aria-labelledby. In an interview, mention <dialog> — but be ready to hand-roll the trap because that's what they're really testing.
Subtleties that separate strong answers
- Focus restoration — store
document.activeElementon open; without this, closing the modal dumps focus to<body>and keyboard users lose their place. inertoveraria-hidden—inertremoves background content from both the tab order and the accessibility tree;aria-hiddenonly does the latter.- Scroll lock — set
overflow: hiddenon<body>while open so the background doesn't scroll behind the modal. alertdialog— use it for confirmations that need an immediate response.- Don't trap with
overflowclipping — that's why the portal matters.
Senior framing
The interviewer is checking whether you know a modal is not just a styled div with position: fixed. The differentiators: trapping and restoring focus, making the background inert, the portal for stacking, and knowing that <dialog>.showModal() gives you most of it natively. Mentioning you'd reach for a battle-tested library (Radix, React Aria) in production — but can implement it — is the mature answer.
Follow-up questions
- •What does <dialog>.showModal() give you that a plain div doesn't?
- •Why use the `inert` attribute instead of aria-hidden on background content?
- •How do you restore focus when the modal closes?
- •When would you use role='alertdialog' instead of role='dialog'?
Common mistakes
- •Not restoring focus to the trigger element on close.
- •Forgetting to trap Tab/Shift+Tab — focus escapes to the background.
- •Rendering the modal inline instead of in a portal, causing z-index/overflow clipping.
- •Closing on clicks inside the modal because the overlay handler isn't stopped.
- •Leaving the background scrollable and readable by screen readers.
Edge cases
- •Modal with no focusable children — focus the dialog container itself (tabindex='-1').
- •Focusable elements added/removed dynamically while the modal is open — recompute the list.
- •Nested modals — each needs its own trap and restoration target.
- •iOS Safari: body scroll lock needs position:fixed tricks, not just overflow:hidden.
Real-world examples
- •Confirmation dialogs, login modals, image lightboxes, cookie consent overlays.