Problem Understanding
A basic modal is easy — a div, some CSS, a button to close it. But a correct modal has three layers that most tutorials skip entirely:
- React Portal — renders at the top of the DOM to avoid clipping issues
- Scroll Lock — prevents the background from scrolling behind the modal
- Focus Management — moves focus into the modal on open, restores it on close
Each of these alone is straightforward. Together, they're what separate a production-quality modal from a homework exercise. The interviewer assigning this question knows exactly which of these the candidate implements, and scores accordingly.
Why Portals? The Real Reason
Consider this scenario: your app has a component with overflow: hidden or a low z-index. If you render a modal inside that component's DOM tree, it will be visually clipped or appear behind other elements regardless of how high you set the modal's z-index. This is because z-index only competes within the same stacking context.
// ❌ Without portal — rendered inside parent DOM
// If any ancestor has overflow:hidden → modal is clipped
// If any ancestor creates a stacking context → z-index is contained
<ParentWithOverflowHidden>
<ChildComponent>
<Modal /> {/* ← trapped inside parent's constraints */}
</ChildComponent>
</ParentWithOverflowHidden>
// ✅ With portal — rendered directly in document.body
// No ancestor constraints apply
// z-index works as expected globally
ReactDOM.createPortal(
<div className="modal-backdrop">...</div>,
document.body // ← renders here, outside all ancestors
)
💡 Interview Tip: Explain the why, not just the how. Say: "I'm using a portal because if the modal is rendered inside the component tree, any ancestor with overflow:hidden would clip it, and any ancestor that creates a new stacking context would contain its z-index. Rendering into document.body via a portal bypasses all of that."
Scroll Lock
useEffect(() => {
if (!isOpen) return;
// Save the original overflow before we change it
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; // lock scroll
return () => {
// Restore it when modal closes or component unmounts
document.body.style.overflow = originalOverflow;
};
}, [isOpen]);
This effect runs when isOpen changes. When it becomes true, we lock the scroll. When it becomes false (or when the component unmounts), the cleanup restores the original value. Saving the original value before overwriting it handles the edge case where the body already had a custom overflow value.
⚠️ Common Mistake: Hardcoding the restore as document.body.style.overflow = ''. This works most of the time but breaks if the body had a pre-existing overflow value set by another part of the app. Always save and restore.
Escape Key Handler
useEffect(() => {
if (!isOpen) return;
function handleEscape(e) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape); // cleanup!
}, [isOpen, onClose]);
⚠️ Common Mistake: Not removing the keydown listener in the cleanup. If the modal opens and closes multiple times, each open adds a new listener without removing the old one. After 5 opens and closes, pressing Escape calls onClose 5 times. Always clean up event listeners.
Focus Management
const panelRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that was focused before the modal opened
const previousFocus = document.activeElement;
// Move focus into the modal
panelRef.current?.focus();
// When the modal closes, return focus to where it was
return () => previousFocus?.focus();
}, [isOpen]);
This is the accessibility detail that distinguishes a senior implementation. When a modal opens, keyboard users and screen reader users need focus to move inside the dialog — otherwise, Tab still navigates the background content behind the overlay. When the modal closes, focus should return to the element that triggered it (usually the button that opened the modal).
💡 Interview Tip: This one genuinely impresses interviewers. Say: "When the modal opens, I move focus inside it so keyboard users can navigate the dialog. I capture document.activeElement before opening, and restore it in the cleanup — so pressing Escape returns focus to the button that opened the modal, exactly as the ARIA specification requires."
Full Component
import ReactDOM from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const panelRef = useRef(null);
// Scroll lock
useEffect(() => {
if (!isOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = original; };
}, [isOpen]);
// Escape key + focus management
useEffect(() => {
if (!isOpen) return;
const previousFocus = document.activeElement;
panelRef.current?.focus();
function handleKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('keydown', handleKey);
previousFocus?.focus(); // restore focus on close
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={panelRef}
className="modal-panel"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1} // makes panel focusable without tab order
onClick={e => e.stopPropagation()} // don't bubble to backdrop
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close">×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
Backdrop Click — The stopPropagation Pattern
Two things must be true simultaneously:
- Clicking the backdrop → modal closes
- Clicking inside the panel → modal stays open
The solution is event bubbling. onClick={onClose} on the backdrop will fire for any click on the backdrop or any of its children (including the panel). e.stopPropagation() on the panel prevents the click event from bubbling up to the backdrop. Result: panel clicks stay local, backdrop clicks close the modal.
Animation
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.15s ease;
}
.modal-panel {
animation: slideUp 0.2s ease;
}
Common Pitfalls Summary
- No portal: Modal gets clipped by overflow:hidden ancestors or has z-index issues in complex layouts.
- No scroll lock: Background scrolls behind the modal — disorienting and amateurish.
- No focus management: Tab key navigates the hidden background while modal is open — a major accessibility failure.
- Not removing keydown listener on close: Multiple listeners accumulate across opens/closes.
- onClick on backdrop and panel (no stopPropagation): Clicking inside the panel closes the modal unexpectedly.
How to Communicate During the Interview
- "A production modal needs three things most implementations miss: a portal to avoid DOM tree constraints, scroll lock to prevent background scroll, and focus management for accessibility."
- "I'm using ReactDOM.createPortal into document.body. This means no ancestor's overflow:hidden or stacking context can affect the modal."
- "The scroll lock saves the original overflow value before overwriting it, so it restores correctly even if the body had a custom overflow."
- "I capture document.activeElement before opening and restore focus to it in the cleanup — so Escape returns focus to the button that opened the modal, as the ARIA spec requires."