AniUI Academy

Portals

Rendering a component's output into a different DOM node than where it lives in the tree — the classic fix for modals and tooltips escaping overflow and stacking-context traps.

8 min read

Everything so far assumes a component's rendered output appears exactly where it sits in the tree — a Modal rendered inside Dashboard produces DOM nested inside Dashboard's own DOM. Usually that's exactly right. Occasionally it's the problem.

The problem: clipping and stacking

CSS properties like overflow: hidden (to clip content within a card) or a stacking context created by transform or z-index don't stop at component boundaries — they apply to real DOM ancestry, regardless of how the React tree is organized:

function Card() {
  return (
    <div style={{ overflow: "hidden", position: "relative" }}>
      <p>Some card content</p>
      <Modal>This gets visually clipped, no matter its own z-index</Modal>
    </div>
  );
}

However high Modal's own z-index is set, if it's rendered as a normal DOM descendant of an element with overflow: hidden, it gets clipped at that ancestor's boundary. This is a real, common problem for anything meant to visually "float above" the rest of the page — modals, tooltips, dropdown menus.

The fix: createPortal

import { createPortal } from "react-dom";
 
function Modal({ children }) {
  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">{children}</div>
    </div>,
    document.getElementById("modal-root")
  );
}

createPortal(children, domNode) renders children into domNode — a real DOM node that can live anywhere in the actual document, commonly a dedicated container like #modal-root placed as a sibling to the app's main root, specifically outside any ancestor that might clip or misorder it:

<body>
  <div id="root"></div>
  <div id="modal-root"></div>
</body>

Modal still logically lives wherever it's rendered in the React tree — inside Card, in the earlier example — but its actual DOM output now appears as a child of #modal-root, entirely outside Card's DOM subtree and whatever clipping it applies.

What still follows the React tree, and what doesn't

This is the detail worth being precise about: a portal changes where the DOM node lands, and nothing else about how the component behaves within React.

  • Event bubbling follows the React tree, not the DOM tree. A click inside a portaled modal still bubbles up through the React component hierarchy the modal logically belongs to — so an onClick on Card (the modal's logical parent) still fires for clicks inside the modal, even though the modal's actual DOM node is physically a sibling of Card's DOM, somewhere else in the document entirely.
  • Context still flows normally. A portaled component reading useContext still sees whatever Provider wraps it in the React tree, regardless of where its DOM output physically renders.
  • Only the DOM placement changes — layout, clipping, and stacking are governed by the real DOM position, which is exactly the thing a portal exists to change.

Try it yourself

Notice the modal visually escapes the clipping card below, while a click inside it still triggers the outer handler — because event bubbling follows the React tree, not the DOM structure:

Try it yourself
Loading playground...

What to remember

  • createPortal(children, domNode) renders a component's DOM output into a different real DOM node, without changing its logical position in the React tree.
  • It's the standard fix for content — modals, tooltips, dropdowns — that needs to escape an ancestor's overflow clipping or stacking context.
  • Event bubbling and context still follow the React tree, not the physical DOM position — only layout/clipping/stacking behavior is what actually changes.
  • A dedicated DOM node (like #modal-root), placed outside the app's main container, is a common target for portaled content.

Check yourself

4 questions · pass 3/4 to unlock forwardRef and Ref as a Prop

up to 50
  1. 1.What does createPortal actually do?

  2. 2.What problem do portals most commonly solve?

  3. 3.An onClick handler inside a portaled modal calls event.stopPropagation() partway up. Does a click handler on a React ancestor still see the event, even though the modal's actual DOM node lives outside that ancestor in the document?

  4. 4.Where does the DOM node passed to createPortal typically come from?

4 left to answer