Machine-Coding Round: Build an Accessible Modal Dialog in React
In front-end engineering interviews—particularly at companies like Meta, Google, Uber, Airbnb, and Stripe—the machine-coding round tests your ability to write clean, production-grade, accessible UI components under a 45-to-60-minute time constraint. Building a modal dialog is one of the most frequently asked UI problems because it touches almost every core browser concept: DOM portals, event bubbling, keyboard event handling, focus management, body scroll locking, and WAI-ARIA accessibility compliance.
Interviewers evaluate:
- Component Architecture: Clean separation between triggers, overlays, portals, and content containers.
- Focus Management: Trapping keyboard focus within the dialog (
Tab/Shift+Tab) and restoring focus to the opening trigger upon dismissal. - Event & Keyboard Handling: Closing on
Escapekey and backdrop clicks while preventing accidental closures from inside clicks. - WAI-ARIA Compliance: Proper roles (
role="dialog"orrole="alertdialog"),aria-modal="true",aria-labelledby, andaria-describedby. - DOM Cleanliness & Scroll Control: Locking background document scrolling cleanly without layout shifting, and using
createPortalto avoid z-index or stacking context clipping.
This guide walks through the complete end-to-end implementation of an accessible React modal dialog tailored for candidates with 0–5 years of experience.
Milestone Index
| Stage | Milestone Focus | Deliverable | Difficulty |
|---|---|---|---|
| 1 | Scoping & Requirements | Functional specs, non-functional criteria, clarifying questions | Junior-Mid |
| 2 | Time Budget & State Plan | 45-minute breakdown, TypeScript props contract, ref architecture | Junior-Mid |
| 3 | Step-by-Step Implementation | Portal mounting, backdrop handling, focus trapping, scroll locking | Mid |
| 4 | Full Production Code | Self-contained, robust TypeScript React component | Mid |
| 5 | Accessibility & Edge Cases | WAI-ARIA validation, focus restoration, event propagation | Mid-Senior |
| 6 | Automated Test Suite | React Testing Library verification covering focus and key events | Mid |
| 7 | Extensions & HTML5 <dialog> | Native dialog comparisons, animations, nested dialogs | Senior |
1. Requirements and Clarifying Questions
Functional Requirements
- Visibility Control: The modal opens when
isOpenistrueand unmounts or hides whenfalse. - Backdrop & Dismissal: Clicking the semi-transparent backdrop or clicking an explicit "Close" button triggers the
onClosecallback. - Keyboard Dismissal: Pressing the
Escapekey immediately closes the modal. - DOM Placement via Portal: The dialog renders into a DOM portal (e.g.,
document.bodyor a dedicated#modal-root) to avoid CSSoverflow: hiddenorz-indexstacking context traps from parent containers. - Body Scroll Lock: While open, background document scrolling is locked to prevent disorienting background scroll jumps.
Non-Functional & Accessibility Requirements
- WAI-ARIA Attributes: The dialog container must have
role="dialog",aria-modal="true", and accessible labeling viaaria-labelledbyandaria-describedby. - Focus Trapping: Keyboard focus must be constrained inside the modal. Pressing
Tabon the last focusable element wraps focus back to the first; pressingShift+Tabon the first element wraps to the last. - Initial Focus: When the modal opens, focus must automatically move to the first interactive element (or close button).
- Focus Restoration: When the modal closes, focus must return to the exact DOM element that triggered it.
Clarifying Questions to Ask the Interviewer
- "Should clicking inside the modal content close the modal?" (No, clicks inside modal content must stop propagation or be distinguished from backdrop clicks).
- "Should the modal be unmounted when closed, or kept in the DOM with CSS visibility?" (Typically unmount from the DOM or hide with
inert). - "Do we need to support animations or transitions on open/close?" (Acknowledge animation timing; implement core functionality first, then add transition support if time permits).
- "Are nested modals expected?" (Design the focus restoration stack so that multiple stacked modals can pop cleanly in LIFO order).
2. Time Budget and State Plan
45-Minute Interview Time Budget
- 00:00 – 05:00 (5 min): Clarify requirements, ask clarifying questions, and agree on the props API.
- 05:00 – 12:00 (7 min): Scaffold component structure, set up React portal via
createPortal, and render backdrop and modal shell. - 12:00 – 25:00 (13 min): Implement keyboard listeners (
Escape), backdrop click detection, and body scroll lock. - 25:00 – 35:00 (10 min): Build the focus trap (finding focusable elements, cycling
Tab/Shift+Tab) and focus restoration ref. - 35:00 – 40:00 (5 min): Apply WAI-ARIA attributes (
role="dialog",aria-modal,aria-labelledby) and test edge cases. - 40:00 – 45:00 (5 min): Write unit tests with React Testing Library and discuss extensions.
Component State & Props Contract
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
initialFocusRef?: React.RefObject<HTMLElement | null>;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
}
State and Ref Plan
modalRef: Ref attached to the dialog content wrapper to scope query selectors for focusable children.triggerElementRef: Ref storingdocument.activeElementwhenisOpentransitions totrue, used to restore focus upon close.- No heavy internal state is needed; the component operates primarily as a controlled component driven by
isOpenandonClose.
3. Step-by-Step Implementation
Step 1: Rendering into a Portal (createPortal)
Modals placed deeply inside a component hierarchy can be clipped by parent styles (such as overflow: hidden, transform, or filter which create new stacking contexts). We render the modal using ReactDOM.createPortal(jsx, document.body).
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
function Portal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
}
Step 2: Locking Background Body Scroll
When a modal opens, background page scrolling should be prevented. We save the original document.body.style.overflow and restore it in cleanup.
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [isOpen]);
Step 3: Keyboard Escape and Backdrop Click Handling
We attach a keydown listener for the Escape key. For backdrop dismissal, we ensure clicks originated on the backdrop container itself rather than bubbling from inside modal content.
useEffect(() => {
if (!isOpen || !closeOnEscape) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, closeOnEscape, onClose]);
Step 4: Focus Trapping and Focus Restoration
A production focus trap queries all interactive elements inside the dialog:
button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]).
When the user presses Tab on the last element, focus cycles to the first element; on Shift+Tab from the first element, focus wraps to the last. When the modal closes, focus returns to triggerElementRef.current.
4. Full Production Implementation
Here is the complete, self-contained TypeScript implementation combining portals, focus trapping, keyboard handling, body scroll locking, and WAI-ARIA compliance:
import React, { useEffect, useRef, useId, useCallback } from 'react';
import { createPortal } from 'react-dom';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
initialFocusRef?: React.RefObject<HTMLElement | null>;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
}
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export function Modal({
isOpen,
onClose,
title,
description,
children,
initialFocusRef,
closeOnBackdropClick = true,
closeOnEscape = true,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
const titleId = useId();
const descriptionId = useId();
// Save the trigger element and restore focus on close
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
} else if (previousActiveElement.current) {
previousActiveElement.current.focus();
previousActiveElement.current = null;
}
}, [isOpen]);
// Lock body scroll while open
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [isOpen]);
// Trap focus and handle Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape' && closeOnEscape) {
e.stopPropagation();
onClose();
return;
}
if (e.key === 'Tab' && modalRef.current) {
const focusableElements = Array.from(
modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
).filter((el) => el.offsetParent !== null); // Filter visible elements
if (focusableElements.length === 0) {
e.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement || document.activeElement === modalRef.current) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
},
[isOpen, closeOnEscape, onClose]
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
// Focus initial element when modal mounts
useEffect(() => {
if (!isOpen) return;
const timer = requestAnimationFrame(() => {
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
} else if (modalRef.current) {
const firstFocusable = modalRef.current.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
if (firstFocusable) {
firstFocusable.focus();
} else {
modalRef.current.focus();
}
}
});
return () => cancelAnimationFrame(timer);
}, [isOpen, initialFocusRef]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-backdrop"
onClick={(e) => {
if (closeOnBackdropClick && e.target === e.currentTarget) {
onClose();
}
}}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
className="modal-content"
style={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '24px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflowY: 'auto',
outline: 'none',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 id={titleId} style={{ margin: 0, fontSize: '1.25rem' }}>
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.25rem' }}
>
✕
</button>
</div>
{description && (
<p id={descriptionId} style={{ color: '#666', marginTop: '8px', marginBottom: '16px' }}>
{description}
</p>
)}
<div style={{ marginTop: '16px' }}>{children}</div>
</div>
</div>,
document.body
);
}
5. Accessibility and Edge Cases
WAI-ARIA Dialog Pattern Audit
role="dialog": Identifies the element as a dialog window that separates content from the rest of the application. If the modal requires immediate confirmation for irreversible actions (e.g. deleting an account), userole="alertdialog".aria-modal="true": Informs assistive technologies that elements outside this container are inert.aria-labelledby={titleId}: Points directly to the heading element containing the dialog's title.aria-describedby={descriptionId}: Points to optional descriptive text, allowing screen readers to announce context immediately upon entry.
Critical Edge Cases Handled
- Accidental Backdrop Click on Text Drag:
- If a user clicks inside the dialog and drags their mouse outward onto the backdrop, firing
onClickon the backdrop could trigger an accidental dismissal. Checkinge.target === e.currentTargeton click guarantees dismissal only occurs when the click originated directly on the backdrop.
- If a user clicks inside the dialog and drags their mouse outward onto the backdrop, firing
- No Interactive Elements Inside:
- If a dialog contains only static text without inputs or buttons, our focus trap focuses
modalRef.current(which hastabIndex={-1}) so screen readers announce the dialog without focus escaping.
- If a dialog contains only static text without inputs or buttons, our focus trap focuses
- Restoring Focus to Unmounted Trigger Elements:
- If the opening trigger button unmounted while the modal was open (e.g., deleting an item from a list),
previousActiveElement.current.focus()could fail. We safely verify existence before focusing.
- If the opening trigger button unmounted while the modal was open (e.g., deleting an item from a list),
- Clean Portal Teardown:
- When
isOpentransitions tofalse, React unmounts the portal node cleanly fromdocument.bodywithout leaving orphaned DOM nodes.
- When
6. Automated Testing with React Testing Library
Here is a comprehensive test suite covering modal rendering, focus trapping, keyboard navigation, and accessibility:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { Modal } from './Modal';
function TestHarness() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Test Dialog">
<input placeholder="First Input" />
<button onClick={() => setIsOpen(false)}>Save</button>
</Modal>
</div>
);
}
describe('Modal Component', () => {
test('opens dialog and focuses the first interactive element', async () => {
const user = userEvent.setup();
render(<TestHarness />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /open modal/i }));
const dialog = screen.getByRole('dialog', { name: /test dialog/i });
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
// First focusable element inside (close button or input) receives focus
expect(screen.getByRole('button', { name: /close dialog/i })).toHaveFocus();
});
test('closes dialog on Escape key and restores focus to trigger', async () => {
const user = userEvent.setup();
render(<TestHarness />);
const openBtn = screen.getByRole('button', { name: /open modal/i });
await user.click(openBtn);
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(openBtn).toHaveFocus(); // Focus restored!
});
test('traps focus within the modal dialog on Tab and Shift+Tab', async () => {
const user = userEvent.setup();
render(<TestHarness />);
await user.click(screen.getByRole('button', { name: /open modal/i }));
const closeBtn = screen.getByRole('button', { name: /close dialog/i });
const input = screen.getByPlaceholderText(/first input/i);
const saveBtn = screen.getByRole('button', { name: /save/i });
expect(closeBtn).toHaveFocus();
await user.tab();
expect(input).toHaveFocus();
await user.tab();
expect(saveBtn).toHaveFocus();
// Tab on last element cycles back to first element
await user.tab();
expect(closeBtn).toHaveFocus();
// Shift+Tab cycles back to last element
await user.tab({ shift: true });
expect(saveBtn).toHaveFocus();
});
});
7. Extensions and Native HTML5 <dialog> Comparison
Comparing Custom Modal vs Native HTML5 <dialog>
In modern browsers, the native <dialog> element provides built-in top-layer rendering and focus management:
| Capability | Custom React Modal (Portal) | Native HTML5 <dialog> |
|---|---|---|
| Top Layer Placement | Simulated via createPortal and high z-index | Native browser top-layer (bypasses all stacking contexts) |
| Backdrop Styling | Custom styled wrapper <div> | Native ::backdrop pseudo-element |
| Focus Trapping | Custom JavaScript keyboard event listener | Built-in native browser focus containment |
| Scroll Lock | Manual overflow: hidden on document.body | Native browser inert background handling |
| Cross-Browser Styling | 100% consistent across all legacy engines | Minor browser-specific default style overrides needed |
In an interview, mentioning the native <dialog> element and dialog.showModal() shows that you understand modern web standards. Explain that custom portal implementations are still widely used in production design systems (like Radix UI, Headless UI, and Material UI) for customizable exit animations and cross-platform SSR compatibility.
Potential Extensions to Discuss
- Stacked / Nested Modals: Maintain a module-level stack array of open modals. The
Escapekey pops only the topmost modal. - Smooth Fade / Slide Animations: Keep the portal mounted while setting an internal animation status (
entering->entered->exiting), delaying unmount untilonAnimationEnd. - Confirmation Guards: Intercept dismissal if the modal contains a dirty form, showing a confirmation prompt before calling
onClose.
Preparation Checklist
To master machine-coding rounds for interactive components, review this actionable checklist:
- Scaffold the Portal: Practice rendering into
createPortal(children, document.body)with client-side mount guards. - Memorize the WAI-ARIA Dialog Contract: Ensure you always add
role="dialog",aria-modal="true", andaria-labelledby. - Implement Tab cycling: Query focusable elements and cycle focus between
[0]and[length - 1]based one.shiftKey. - Always restore focus: Capture
document.activeElementon open and restore focus on dismissal. - Safely lock body scroll: Store and restore
document.body.style.overflowcleanly inside auseEffectcleanup hook. - Distinguish backdrop clicks: Check
e.target === e.currentTargetto prevent accidental dismissal during internal selection drags. - Know the native
<dialog>API: Be ready to compare custom portals withHTMLDialogElement.showModal().
Conclusion
Building an accessible modal dialog in a machine-coding interview demonstrates your mastery of real-world front-end engineering. By seamlessly integrating React portals, focus trapping, scroll management, and WAI-ARIA standards, you showcase the architectural rigor and attention to detail that top engineering teams look for.