Back

Modal System

Medium

Streak

0 days

Progress

0%

Submitted

0

Modal System

React35 minMediumFreeNew

Prompt

A Modal dialog is one of the most deceptively complex UI components to implement correctly. Your task is to build a reusable Modal component in React that renders a centered overlay dialog with a semi-transparent backdrop, and closes when the user clicks the backdrop, presses Escape, or clicks an explicit close button. What makes this a senior-level question is not the visual design — it's the three layers of correctness that most candidates miss entirely: React Portals (rendering outside the component tree to avoid z-index and overflow clipping issues), body scroll locking (preventing the background from scrolling while the modal is open), and focus management (moving focus into the modal when it opens, returning it to the trigger when it closes). Together, these three requirements define what a production-quality modal actually looks like. Your implementation should accept isOpen and onClose as controlled props, render using ReactDOM.createPortal, lock body scroll while open, manage focus correctly, and handle the Escape key.

Requirements

  • →Renders a centered dialog with a semi-transparent dark backdrop
  • →Uses ReactDOM.createPortal to render into document.body (not inline in the component tree)
  • →Clicking the backdrop closes the modal (but clicking inside the panel does not)
  • →Pressing the Escape key closes the modal
  • →Body scroll is locked while the modal is open (restored when closed)
  • →Focus moves into the modal when opened, returns to the trigger element when closed
  • →Smooth fade/slide-in animation on open
Example
Loading preview...
For the best coding experience, we recommend using a desktop device.
Preparing Sandbox...
Premium interview report

What interviewers score in this build

Use this before reading the code. It tells you what to say, what to test, and where machine-coding candidates usually lose points.

Interview signals

  • React: ReactDOM.createPortal renders into document.body. useRef for modal panel (focus management). useEffect for scroll lock + Escape key listener, both with cleanup. Returns null when isOpen is false.
  • HTML/CSS: role=dialog, aria-modal=true, aria-labelledby on panel. Backdrop is position:fixed, inset:0. Panel is centered with flexbox on the backdrop. Entrance animation with CSS keyframes.
  • Component Architecture: Controlled component — accepts isOpen and onClose as props. Content passed as children. Trigger button and state live in the parent. Modal itself is stateless.
  • State Management: Modal has no internal state — completely controlled by parent. Focus management uses refs. Scroll lock is a side effect managed by useEffect.

Time checkpoints

  1. 1

    0:00: Read prompt. Note the three advanced requirements: portal, scroll lock, focus management. Confirm with interviewer that all three are expected.

  2. 2

    3:00: Build basic modal structure: backdrop div, panel div, close button. Get the visual layout right.

  3. 3

    8:00: Wrap in ReactDOM.createPortal. Verify it renders in document.body (not inside the component tree).

  4. 4

    13:00: Add scroll lock useEffect. Add Escape key listener useEffect. Both with cleanup.

Edge-case checklist

Empty data and first-load state
Slow network, failed request, and retry path
Keyboard navigation and focus movement
Large input size, re-render pressure, and cleanup

Common mistakes

  • Starting with JSX before naming state and events.
  • Ignoring accessibility until the final minute.
  • Over-building abstractions instead of finishing the required behavior.
  • Failing to narrate trade-offs while coding.
SolutionRead-only · Live Preview

Technical Explanation

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:

  1. React Portal — renders at the top of the DOM to avoid clipping issues
  2. Scroll Lock — prevents the background from scrolling behind the modal
  3. 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."

Interview Criteria

React

ReactDOM.createPortal renders into document.body. useRef for modal panel (focus management). useEffect for scroll lock + Escape key listener, both with cleanup. Returns null when isOpen is false.

HTML/CSS

role=dialog, aria-modal=true, aria-labelledby on panel. Backdrop is position:fixed, inset:0. Panel is centered with flexbox on the backdrop. Entrance animation with CSS keyframes.

Component Architecture

Controlled component — accepts isOpen and onClose as props. Content passed as children. Trigger button and state live in the parent. Modal itself is stateless.

State Management

Modal has no internal state — completely controlled by parent. Focus management uses refs. Scroll lock is a side effect managed by useEffect.

Edge Cases

Scroll lock is cleaned up on unmount even if modal is still open. Escape key listener is removed on close/unmount. Backdrop click does not fire when clicking inside the panel. Focus returns to the element that opened the modal.

Time Checkpoints

0:00

0:00: Read prompt. Note the three advanced requirements: portal, scroll lock, focus management. Confirm with interviewer that all three are expected.

3:00

3:00: Build basic modal structure: backdrop div, panel div, close button. Get the visual layout right.

8:00

8:00: Wrap in ReactDOM.createPortal. Verify it renders in document.body (not inside the component tree).

13:00

13:00: Add scroll lock useEffect. Add Escape key listener useEffect. Both with cleanup.

19:00

19:00: Add focus management: focus the panel on open, return focus to trigger on close.

24:00

24:00: Add backdrop click (onClose) with stopPropagation on panel. Add ARIA attributes.

28:00

28:00: Add entrance animation, test all close interactions, handle edge cases.

Streak

0 days

Last active: Sign in to track

Progress

0%

0/0 solved

Submitted

0

Solutions pushed to review history.