How to Create a Modal Using React Portals

Advanced10 min interview
Skills tested:
Using ReactDOM.createPortal to render outside the React root DOM nodeTrapping focus inside the modal for accessibility (focus lock, ESC key close)Preventing body scroll while the modal is openCleaning up body scroll lock and event listeners when the modal unmountsUnderstanding that portals remain in the React tree for context and event bubbling

Advertisement

🧩 Scenario

Portals solve a specific z-index and overflow problem. Interviewers look for whether you understand why portals exist (not just how to use the API) and whether you handle the accessibility requirements that make modals production-ready.

Architecture Walkthrough

Why Portals Exist

Modals and tooltips rendered as children of their trigger element inherit the trigger's CSS stacking context. An overflow: hidden container clips the modal. A parent with a low z-index puts the modal behind sibling elements. These are not bugs that CSS alone can solve because the problem is the DOM hierarchy.

ReactDOM.createPortal(children, container) renders the children into container — a DOM node that can be anywhere in the document, such as document.body or a dedicated #portal-root div — while keeping the component in the React component tree. The result is that the modal escapes all CSS constraints from its React ancestors, but still receives context values, has event bubbling propagate up the React tree, and stays in the React component lifecycle.

Accessibility Requirements

A modal that is visually correct but keyboard-inaccessible is not production-ready. Three requirements are non-negotiable. Focus must be trapped inside the modal: when the modal opens, focus moves to the first focusable element inside it, and Tab cycles only through elements within the modal. The Escape key must close the modal. When the modal closes, focus returns to the element that triggered it. Without these, keyboard and screen reader users either cannot interact with the modal or lose their place in the page when it closes.

Body scroll must also be prevented while the modal is open. Without this, the background page scrolls while the modal is in front, which is disorienting. The standard technique is document.body.style.overflow = 'hidden' on open and restoring it on close, ideally in the useEffect cleanup.


Key Code Explained

import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const previousFocusRef = useRef<HTMLElement | null>(null);
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    // Save current focus so we can restore it on close
    previousFocusRef.current = document.activeElement as HTMLElement;

    // Prevent body scroll while modal is open
    document.body.style.overflow = 'hidden';

    // Move focus into the modal
    modalRef.current?.focus();

    // Close on Escape key
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleKeyDown);

    return () => {
      // Cleanup: restore scroll and focus
      document.body.style.overflow = '';
      document.removeEventListener('keydown', handleKeyDown);
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    // Overlay: clicking it closes the modal
    <div
      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
      onClick={onClose}
      role="presentation"
    >
      {/* Modal panel: stop click propagation to overlay */}
      <div
        ref={modalRef}
        className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()} // prevent overlay close on content click
      >
        <div className="flex justify-between items-center mb-4">
          <h2 id="modal-title" className="text-lg font-semibold">
            {title}
          </h2>
          <button
            onClick={onClose}
            aria-label="Close modal"
            className="text-gray-500 hover:text-gray-700"
          >
            X
          </button>
        </div>
        {children}
      </div>
    </div>,
    // Render into a node outside the React root
    document.body
  );
}


// Usage
function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>

      <Modal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="Confirm Delete"
      >
        <p>Are you sure you want to delete this item?</p>
        <div className="flex gap-2 mt-4">
          <button onClick={() => setIsOpen(false)}>Cancel</button>
          <button onClick={handleDelete}>Delete</button>
        </div>
      </Modal>
    </div>
  );
}

The e.stopPropagation() on the modal panel is the fix for clicks inside the content closing the modal. Without it, a click on the modal content bubbles up to the overlay div, which calls onClose. The tabIndex={-1} on the modal panel makes it programmatically focusable (so modalRef.current?.focus() works) without putting it into the tab order (users Tab to actual interactive elements inside it).


Tradeoffs

Patternz-index isolationCSS overflow escapeContext accessFocus management
Child in component treeNoNoYesManual
Portal to document.bodyYesYesYesManual
Library (Radix, Headless UI)YesYesYesBuilt-in

What Interviewers Actually Check

  • Whether you know why portals exist (z-index and overflow:hidden) not just how to use the API
  • Whether you know portals stay in the React tree for context and event bubbling
  • Whether you handle focus trap, Escape key, and focus restoration on close
  • Whether you prevent body scroll and restore it in cleanup
  • Whether you handle the overlay-click-closes vs content-click-closes distinction

Follow-Up Questions

  1. How would you implement a focus trap that keeps Tab and Shift+Tab cycling only through focusable elements inside the modal?
  2. A tooltip using a portal flickers on every render because document.getElementById('portal-root') is called inline. How do you fix this?
  3. How do portals interact with event bubbling? If a button inside a portal dispatches a click event, does the parent React component's onClick handler fire?
  4. In React 18, is there a way to create portals declaratively without ReactDOM.createPortal? What is it?
  5. How would you animate the modal entrance and exit while keeping the portal cleanup correct?

Common Candidate Mistakes

  • Not adding e.stopPropagation() to the modal content div — clicks inside close the modal
  • Not handling Escape key — keyboard users cannot close the modal without a mouse
  • Not preventing body scroll — the background page scrolls while the modal is open
  • Not restoring focus to the trigger element after close — screen reader users lose their page position
  • Calling document.getElementById('portal-root') every render instead of once via useRef or a module-level constant

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you implement createPortal targeting document.body or a dedicated container?
  • Can you explain why portals are needed (overflow:hidden and z-index stacking context)?
  • Can you explain that portals stay in the React component tree for context and events?
  • Can you implement Escape-to-close, body scroll lock, and focus restoration?
  • Can you prevent overlay clicks from closing the modal when clicking modal content?

Summary

ReactDOM.createPortal(children, container) renders children into a DOM node outside the React root, allowing modals and overlays to escape overflow: hidden and z-index stacking context constraints. The component remains in the React tree, so it retains context access and event bubbling behaves according to the React tree hierarchy. Production-ready modals require four accessibility behaviors: focus moves into the modal on open, Tab is trapped inside, Escape closes the modal, and focus returns to the trigger element on close. Body scroll must be prevented while the modal is open and restored in the useEffect cleanup.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Does a Portal component still have access to React context?

Yes. A portal remains part of the React component tree regardless of where it renders in the DOM. It can access any context provided by an ancestor in the React tree, even though the actual DOM node is outside the React root. Event bubbling also follows the React tree, not the DOM tree.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement