Build an accessible modal dialog
Design the behavior contract for an accessible modal dialog. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.
Answer Strategy
Start with the interview thesis: a modal is open state, focus isolation, and a labelled dialog. If those three pieces are correct, the visual shell can change without breaking the user contract.
Name ownership before writing JSX. The parent owns open/closed state. The modal reports close intent. The dialog owns temporary focus behavior while it is mounted. Product code owns the save/cancel side effects. That split keeps the modal reusable and prevents hidden global state.
Then narrate the dangerous cases: initial focus, Tab and Shift+Tab loops, Escape, backdrop clicks, scroll locking, focus restoration, nested overlays, and tests that use roles and names instead of implementation selectors.
Reference Implementation: Accessible Modal Primitive
This React version keeps the state API small and makes focus behavior explicit. The executable sandbox below uses the same contract with plain DOM code so you can run it directly in the browser.
// Interview contract: the parent controls visibility; the modal reports intent.
// The component should not decide whether "save" succeeded or where to route next.
type ModalProps = {
open: boolean;
title: string;
description?: string;
onClose: (reason: 'escape' | 'backdrop' | 'cancel' | 'submit') => void;
children: React.ReactNode;
footer: React.ReactNode;
};
// Keep the focus trap strict: only currently usable interactive elements count.
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function getFocusable(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
.filter((element) => !element.hasAttribute('disabled'));
}
function AccessibleModal({
open,
title,
description,
onClose,
children,
footer,
}: ModalProps) {
const dialogRef = React.useRef<HTMLDivElement | null>(null);
// Store the opener so closing the modal returns the user to their workflow.
const previousFocusRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
if (!open) return;
previousFocusRef.current = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (!dialog) return;
// Initial focus should land inside the dialog, not stay behind the overlay.
const focusable = getFocusable(dialog);
(focusable[0] ?? dialog).focus();
document.body.style.overflow = 'hidden';
function onKeyDown(event: KeyboardEvent) {
// Escape is a close intent. Prevent default so the browser does not
// accidentally trigger another focused control while the dialog closes.
if (event.key === 'Escape') {
event.preventDefault();
onClose('escape');
return;
}
if (event.key !== 'Tab') return;
const items = getFocusable(dialog);
if (items.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
// Cycle focus inside the modal. Without this, keyboard users can tab
// into the dimmed page behind the dialog.
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', onKeyDown);
return () => {
// Cleanup is correctness: remove global listeners, unlock the page, and
// restore focus to the element that opened the modal.
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = '';
previousFocusRef.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="overlay"
onMouseDown={(event)=> {
// Only the backdrop closes the modal. Clicks inside dialog content
// should not bubble into an accidental dismiss.
if (event.target= event.currentTarget) onClose('backdrop');
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
// The accessible name and description are the screen reader contract.
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
className="dialog"
>
<h2 id={titleId}>{title}</h2>
{description && <p id={descriptionId}>{description}</p>}
<div>{children}</div>
<footer>{footer}</footer>
</div>
</div>
);
}Executable UI Sandbox
UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.
Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('traps focus, closes on escape, and restores focus', async () => {
const user = userEvent.setup();
render(<SettingsExample />);
// Query by role and accessible name. That proves the component is usable,
// not just that a class name exists.
const opener = screen.getByRole('button', { name: /open settings/i });
await user.click(opener);
const dialog = screen.getByRole('dialog', { name: /workspace settings/i });
expect(dialog).toBeInTheDocument();
expect(screen.getByLabelText(/workspace name/i)).toHaveFocus();
// Drive the same keyboard path a real user would use.
await user.keyboard('{Tab}{Tab}{Tab}');
expect(screen.getByRole('button', { name: /cancel/i })).toHaveFocus();
// The close behavior must restore context, otherwise keyboard users are lost.
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});Interviewer Signal
Shows whether you can build components as interaction systems rather than visual boxes.
Constraints
- Name the controlled and uncontrolled state.
- Define keyboard and focus behavior.
- Include loading, empty, disabled, and error states.
Model Answer Shape
- Start with the accessibility role and interaction contract.
- Separate rendering slots from state management.
- Expose callbacks that describe user intent, not internal implementation details.
Tradeoffs
- A headless primitive is reusable but slower to consume.
- A product-specific component ships faster but can trap behavior in one use case.
Edge Cases
- Focus after close, selection, deletion, or route change.
- Large datasets and slow network responses.
- Screen reader labels and live updates.
Testing And Proof
- Keyboard path through the primary workflow.
- A11y names, descriptions, and roles.
- State transition after slow or failed data load.
Follow-Ups
- How would this component be documented in a design system?
- What props would you refuse to expose?