Skip to the content.

Portals render outside the tree so overlays escape their parents

Portals render outside the tree so overlays escape their parents

A portal renders a component’s DOM output somewhere else in the document — usually a node at the end of <body> — while keeping the component exactly where it is in the React tree. That split is the whole trick, and it exists to solve one stubborn problem: overlays. A modal, a dropdown, a tooltip logically belongs to the component that opened it (it needs that component’s props and state), but it must escape that component’s DOM box to avoid being clipped by overflow: hidden or buried by a z-index it cannot win. A portal lets an overlay keep its logical parent and its React state while breaking out of its physical container.

A portal keeps a component in the React tree but renders its DOM at the body In the React tree the Modal is a child of Card. In the DOM its output is rendered into a portal root at the end of body, escaping the card's overflow and stacking context. React tree Card Modal (child) props + state stay here DOM card (overflow:hidden) #portal-root (end of body)modal DOM lives here
Logically the Modal is Card's child (props and state flow normally); physically its DOM is rendered into a root at the end of body, clear of the card's clipping.

The overflow and z-index trap

Without a portal, an overlay renders inside its parent’s DOM, and it inherits the parent’s constraints. If any ancestor has overflow: hidden (extremely common on cards, scroll areas, and tables), the overlay is clipped at the box edge. If an ancestor establishes a stacking context with a lower z-index than a sibling elsewhere, no z-index you set on the overlay can lift it above that sibling — stacking contexts are not global. These are not bugs in your CSS; they are the overlay being trapped in the wrong part of the tree:

/* extremely common — and it silently clips any overlay rendered inside */
.card {
  overflow: hidden;      /* rounds the corners… and crops the dropdown */
  transform: translateZ(0);  /* creates a stacking context: z-index is now local */
}
.card .dropdown { z-index: 9999; }  /* still can't beat a sibling outside .card */

Render into a node outside the trap

createPortal takes the JSX and a target DOM node, and renders there instead of in place. The component stays a normal child in React — it receives props, holds state, and dispatches events up the React tree as usual:

import { createPortal } from "react-dom";

function Modal({ open, onClose, children }) {
  if (!open) return null;
  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div className="modal" role="dialog" aria-modal="true">{children}</div>
    </div>,
    document.getElementById("portal-root")   // renders at the end of <body>
  );
}

The #portal-root sits at the top level of the DOM, free of any ancestor’s overflow or stacking context, so the modal is never clipped and its z-index behaves globally.

Events still follow the React tree, and a11y still needs care

The detail that surprises people: because the component stays in the React tree, events bubble through the React parent, not the DOM parent. A click inside the portal reaches an onClick on the logical parent component even though the DOM node lives elsewhere — which is usually what you want, and occasionally a gotcha if you relied on DOM-based bubbling. The portal solves placement, not accessibility: a portalled modal still needs focus moved into it, focus trapped, Escape to close, and focus restored on close — the DOM location does not do any of that for you. Use portals for exactly the things that must escape their container — modals, dropdowns, tooltips, toasts — and pair them with the focus choreography an overlay requires. The accessible-combobox exercise combines both: a listbox that must escape its input’s overflow and manage focus correctly, which is the portal pattern at full strength.