React Portals

React portals let you render a component's output into a DOM node that lives outside its parent's DOM hierarchy while keeping it fully inside the same React component tree.

What a Portal Actually Does

Normally, when a component renders JSX, React inserts the resulting DOM nodes as children of its parent's DOM node. A portal breaks that link: the JSX still belongs to the same React component tree — it still receives props, context, and state updates from its logical parent — but the actual DOM nodes it produces are attached somewhere else entirely, such as a <div id="modal-root"> sitting directly under <body>.

Why You'd Want to Escape the DOM Tree

The most common reason is CSS. If a component lives deep inside a container with overflow: hidden, a transform, or a low z-index, anything you render inside it — like a modal or tooltip — inherits those constraints and can get clipped or hidden behind other elements. Rendering that overlay through a portal into a sibling of <body> sidesteps the problem completely, because the DOM node is no longer a descendant of the clipping container.

  • Modal dialogs and confirmation prompts
  • Tooltips and popovers that must escape clipped containers
  • Toast and notification stacks anchored to the viewport
  • Dropdown menus that need to overlay unrelated page sections
  • Full-screen loading overlays

A Basic Modal Portal

import { useState } from 'react';
import { createPortal } from 'react-dom';

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.getElementById('modal-root')
  );
}

function App() {
  const [open, setOpen] = useState(false);

  return (
    <div className="app">
      <button onClick={() => setOpen(true)}>Open Modal</button>
      <Modal isOpen={open} onClose={() => setOpen(false)}>
        <h2>Hello from a portal</h2>
        <p>This markup lives outside #app in the DOM.</p>
      </Modal>
      <div id="modal-root"></div>
    </div>
  );
}

export default App;

Notice that Modal still looks like an ordinary component from the outside — you mount it, pass it props, and it disappears when isOpen is false. The only difference is where its markup physically lands in the DOM. createPortal takes two arguments: the JSX you want to render, and the target DOM node to render it into.

Detecting Clicks Outside via Bubbling

import { useState } from 'react';
import { createPortal } from 'react-dom';

function Dropdown({ label, children }) {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <button onClick={() => setOpen(true)}>{label}</button>
      {open &&
        createPortal(
          <ul
            className="dropdown-menu"
            onClick={(e) => {
              // This click physically happens inside document.body,
              // but React still delivers it through this component's
              // own event handlers, because portals only change DOM
              // placement, not the React tree.
              e.stopPropagation();
              setOpen(false);
            }}
          >
            {children}
          </ul>,
          document.body
        )}
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 20 }}>
      <Dropdown label="Options">
        <li>Item 1</li>
        <li>Item 2</li>
      </Dropdown>
    </div>
  );
}
Note: Because clicks bubble through the React tree, a handler on a wrapping element still fires for clicks inside a portaled child. For robust 'click outside to close' behavior, many teams still add a document-level listener with a ref check as well, since it also catches focus changes consistently across browsers.

A Reusable Modal Component

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

function useEscapeKey(onEscape) {
  useEffect(() => {
    function handleKeyDown(e) {
      if (e.key === 'Escape') onEscape();
    }
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [onEscape]);
}

function ConfirmModal({ isOpen, title, onConfirm, onCancel }) {
  const dialogRef = useRef(null);
  useEscapeKey(onCancel);

  useEffect(() => {
    if (isOpen) dialogRef.current?.focus();
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div
        ref={dialogRef}
        className="modal-dialog"
        role="dialog"
        aria-modal="true"
        tabIndex={-1}
      >
        <h2>{title}</h2>
        <button onClick={onConfirm}>Confirm</button>
        <button onClick={onCancel}>Cancel</button>
      </div>
    </div>,
    document.getElementById('modal-root')
  );
}

export default function App() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setOpen(true)}>Delete item</button>
      <ConfirmModal
        isOpen={open}
        title="Are you sure?"
        onConfirm={() => {
          console.log('Confirmed');
          setOpen(false);
        }}
        onCancel={() => setOpen(false)}
      />
      <div id="modal-root"></div>
    </div>
  );
}
AspectRegular RenderPortal Render (createPortal)
DOM placementNested inside the parent's DOM nodeAttached to any DOM node you choose, anywhere in the document
CSS inheritance / clippingInherits overflow, z-index, and transform contexts from ancestorsEscapes ancestor clipping and stacking contexts
Position in React treeChild of its rendering parentStill a child of its rendering parent — props, context, and state work normally
Event bubblingBubbles through matching DOM ancestorsBubbles through React ancestors, not DOM ancestors
Typical useOrdinary page contentModals, tooltips, toasts, dropdowns
Note: Always make sure the portal's target node exists before you render into it. Calling document.getElementById for a node that hasn't been mounted yet — or that a previous unmount already removed — causes createPortal to throw. Many teams create a single persistent #modal-root div in index.html specifically so it's always available.

Exercise: React Portals

What does ReactDOM.createPortal fundamentally let you do?