The modal manager round tests whether the candidate can build a robust, accessible modal system from scratch. Modals are deceptively complex: stacking multiple modals, returning focus to the originating element on close, trapping focus inside the modal, ESC handling, scroll lock, and accessibility-correct ARIA attributes are all part of the round.
This piece walks through the implementation patterns, the accessibility requirements, and what interviewers grade.
The typical prompt
- “Build a modal component. Multiple modals can be open simultaneously, stacked.”
- “Implement a modal manager with React Portals.”
- “Build a confirmation dialog system.”
What interviewers grade
- Working modal display. Modal opens, shows over the content, can be closed.
- ESC key closes the modal. Closes the topmost modal, not all of them.
- Focus management. Focus moves into the modal on open; returns to the trigger on close.
- Focus trap. Tab cycles within the modal; doesn’t escape to background content.
- Backdrop click. Clicking outside the modal usually closes it (configurable).
- Body scroll lock. Page behind doesn’t scroll when modal is open.
- Stacking. Multiple modals stack correctly with z-index.
- Accessibility. ARIA dialog role, aria-modal, aria-labelledby for the title.
Implementation: a robust pattern
Step 1: portals and the modal root
function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={e => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.getElementById('modal-root')
);
}
The portal pulls the modal out of the React tree’s DOM hierarchy, attaching it to a known container. This avoids CSS issues with overflow:hidden ancestors and z-index stacking.
Step 2: ESC handling
useEffect(() => {
if (!isOpen) return;
const handler = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
For stacked modals, only the topmost should respond to ESC. The cleanest pattern uses a stack in a context provider; the topmost modal subscribes to keyboard events.
Step 3: focus management
const dialogRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Save what was focused before
previouslyFocusedRef.current = document.activeElement;
// Focus the dialog (or first focusable element inside)
dialogRef.current?.focus();
return () => {
// Restore focus on unmount
previouslyFocusedRef.current?.focus();
};
}, [isOpen]);
The dialog itself needs tabIndex={-1} to be focusable.
Step 4: focus trap
When the user presses Tab, focus must cycle within the modal, not escape to background content.
function handleKeyDown(e) {
if (e.key !== 'Tab') return;
const focusable = dialogRef.current.querySelectorAll(
'button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
This is a hand-rolled focus trap. In production, use the focus-trap library. For the interview, demonstrating that you know the concept and can implement it is the bar.
Step 5: body scroll lock
useEffect(() => {
if (!isOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original;
};
}, [isOpen]);
For stacked modals, the lock should be applied once for any open modal and released only when all are closed. The cleanest implementation uses a counter in a context provider.
Stacking modals
To support multiple modals stacking, hoist the management into a context:
const ModalContext = createContext();
function ModalProvider({ children }) {
const [stack, setStack] = useState([]);
const open = (config) => setStack(s => [...s, { id: Date.now(), ...config }]);
const close = (id) => setStack(s => s.filter(m => m.id !== id));
return (
<ModalContext.Provider value={{ open, close }}>
{children}
{stack.map((m, i) => (
<Modal key={m.id} {...m} onClose={() => close(m.id)} isTop={i === stack.length - 1} />
))}
</ModalContext.Provider>
);
}
The isTop flag tells each modal whether it’s the topmost (only that one responds to ESC, manages focus on close, etc.).
Common pitfalls
- Forgetting focus restoration. The user’s tab order is broken after closing the modal.
- No focus trap. Tab escapes to background content; screen readers also wander out.
- ESC closes all modals at once. Should close just the topmost.
- Backdrop click closes the modal even when child clicks bubble up. Use stopPropagation on the dialog content.
- No body scroll lock. Background scrolls behind the modal.
- Z-index hell. Portals avoid most z-index issues, but stacking modals still need explicit z-index management.
- aria-modal=true with aria-hidden=false on background. Combination is wrong; aria-modal already implies background should be inert.
Stretch goals
- Animated open / close transitions.
- Confirmation dialog API:
const result = await confirm({ title, message }). - Different modal sizes / variants (alert, confirm, custom).
- Modal scrolling when content overflows.
- Focus return when user closes via Tab+Escape.
Time budget for a 45-minute round
- 0-5 min: clarify requirements (stacking? animations? confirmation API?).
- 5-15 min: basic modal with portal, open/close, ESC handling.
- 15-25 min: focus management and focus trap.
- 25-35 min: body scroll lock, backdrop click, accessibility attributes.
- 35-40 min: stacking with context provider.
- 40-45 min: polish, edge cases.
Frequently Asked Questions
Should I use a library?
For the round, no. In production, Radix UI’s Dialog or React Aria’s Dialog are excellent choices.
Is the focus trap really necessary?
For accessibility yes. WCAG 2.1 requires it for modal dialogs. Senior+ rounds grade for it.
What about the inert attribute for background content?
The HTML inert attribute now exists and can be used for modal backgrounds. Newer pattern; not yet universal but worth knowing for senior+ candidates.
How does this differ from a popover or tooltip?
Modals trap focus and demand interaction; popovers and tooltips don’t. The aria-modal attribute distinguishes them. Different patterns; don’t conflate.
Are nested modals a real-world need?
Sometimes — confirm dialogs spawned from inside a modal, for instance. Many design teams discourage them; the pattern still needs to be supported.