Skip to main content

Portals: Rendering Outside the Box

What you'll learn

  • What createPortal(children, domNode) does, and what it pointedly does not change
  • The modal problem: why overflow and stacking contexts can imprison your overlay
  • The mind-bender: events from a portal bubble through the React tree, not the DOM tree
  • Context, state, and effects inside portals (spoiler: everything is normal)
  • A complete modal: overlay click to close, Escape key, focus on open
  • Gotchas: scoped CSS, focus management, accessibility, and SSR

Your <Modal /> is buried twelve components deep, inside a card. That card has overflow: hidden, so the overlay gets clipped to a rounded rectangle. Or an ancestor has a transform, which silently creates a new stacking context, and your z-index: 9999 now competes only with siblings inside the card. No CSS value rescues a node that is physically imprisoned by its ancestors. The escape is to move the DOM node somewhere free, without losing anything React gives you. That is exactly one API.

createPortal: two trees, two answers

import { createPortal } from 'react-dom';

function Modal({ children }) {
return createPortal(
<div className="overlay">{children}</div>,
document.body
);
}

Jargon: "portal". A way to render children into a DOM node outside the parent component's DOM subtree, while keeping those children at the exact same position in the React tree. First argument: the children. Second: the real DOM node to mount them under.

The two properties that matter, stated plainly:

  1. DOM-wise, the node lives elsewhere. The overlay div becomes a child of document.body. Inspect the page: it is not inside your root div at all. No ancestor of your component can clip it, transform it, filter it, or trap its z-index.
  2. React-wise, nothing changes. In the internal tree, the portal's children sit exactly where you wrote them, inside Modal, inside whatever rendered Modal. State, effects, context, and (the big one, below) events all follow the React tree.

Pseudocode model, not real source:

// A portal is an internal node like any other —
// it just knows its DOM children live elsewhere:
const portalNode = {
tag: 'Portal',
parent: modalFiber, // React parent: perfectly normal
domContainer: document.body, // where its children actually mount
};

The event mind-bender

Here is the demo that makes portals click. A parent counts every click inside its div, and a portal tries to sneak past it:

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

function Modal({ onClose }) {
  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div className="dialog" onClick={e => e.stopPropagation()}>
        <h2>Hello from the portal</h2>
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

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

  return (
    <div onClick={() => setClicks(c => c + 1)}>
      <p>Clicks counted by this box: {clicks}</p>
      <button onClick={() => setOpen(true)}>Open modal</button>
      {open && <Modal onClose={() => setOpen(false)} />}
    </div>
  );
}

What happens when you click the gray overlay background:

  1. DOM-wise, the overlay is a child of document.body. Native bubbling goes overlay → body → html → document, it never passes through App's div. If events followed the DOM, the counter would stay put.
  2. But React's root listener catches the event and maps the overlay back to its internal node, and in the internal tree, the overlay sits inside Modal, inside App's div.
  3. React dispatches along that path (two chapters ago): the overlay's onClick fires (closing the modal), then App's div onClick fires, the counter increments, courtesy of a click on a DOM node that is not inside the div that counted it.
  4. Clicks inside the dialog stop at the dialog's stopPropagation, exactly as if the dialog were ordinary nested JSX, because React-wise, it is.

The takeaway worth tattooing: React events bubble through the React tree, not the DOM tree. Dispatch always follows the internal structure (chapter 2); a portal just makes the difference visible. Context tells the same story, a portal child reads context from its React ancestors exactly as if it had never left. It never did leave. Only its DOM did.

Practical corollary: this is usually a feature (a modal can rely on a parent's handler), and occasionally a surprise, a portal rendered inside a clickable row will trigger the row's onClick. A stopPropagation at the portal's top element, as in the dialog above, fixes that.

Everything else is just normal

State in a portal component: normal. Effects: run and clean up on the usual schedule. Context: flows in from React ancestors. Refs to portal children: attach normally. The portal changes where pixels live and nothing else.

Use cases, all the same shape, anything that must visually escape its parent while logically remaining its child: modals, tooltips, toasts, dropdown menus, hovercards.

The complete modal

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

function Modal({ onClose, children }) {
  const dialogRef = useRef(null);

  useEffect(() => {
    dialogRef.current.focus();

    function onKeyDown(e) {
      if (e.key === 'Escape') onClose();
    }
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [onClose]);

  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div
        className="dialog"
        role="dialog"
        aria-modal="true"
        tabIndex={-1}
        ref={dialogRef}
        onClick={e => e.stopPropagation()}
      >
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

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

  return (
    <main>
      <h1>My app</h1>
      <button onClick={() => setOpen(true)}>Open modal</button>
      {open && (
        <Modal onClose={() => setOpen(false)}>
          <h2>Settings</h2>
          <p>Everything here renders under document.body.</p>
        </Modal>
      )}
    </main>
  );
}

What happens, piece by piece:

  1. Open: setOpen(true) mounts Modal; its DOM is built in memory and inserted under document.body, the first-render pipeline from chapter 1, miniaturized to one subtree.
  2. Focus: the effect runs after paint; dialogRef.current.focus() moves keyboard focus into the dialog. tabIndex={-1} makes the div focusable without adding it to the tab order.
  3. Overlay click: the overlay's own onClick closes the modal; the dialog's stopPropagation keeps clicks inside the dialog from counting as overlay clicks.
  4. Escape: a plain document listener in an effect, removed by the cleanup the moment the modal unmounts.
  5. Accessibility: role="dialog" plus aria-modal="true" tells assistive technology that the rest of the page is inert while this is open.
  6. Close: the subtree is removed from document.body, cleanups first, then the cut, exactly the deletion order from the commit chapter.

Gotchas worth the bruises

  • Scoped CSS doesn't cross. The portal node is outside your root element. Styles scoped under .app, a theme class on your root div, or CSS that assumes the overlay is nested inside your layout won't reach it. Fix: portal into a wrapper that carries your theme classes, or keep overlay styles global.
  • Focus management is your job. Focus the dialog on open (sketched above), return focus to the opener on close, and trap Tab inside while open, the browser does none of it for you. Mature libraries exist for when you outgrow the sketch.
  • Accessibility is your job too. aria-modal, labeling the dialog (aria-labelledby), and making the background inert are on you.
  • SSR: the target must exist. On the server there is no document.body. Render the portal only after mount (a mounted flag set in an effect), or guarantee the target node exists in your HTML. The server chapter returns to this.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: A portal is a second React root. Reality: portal children live in the same internal tree under the same root; only their DOM parent changes. A second root (chapter 1) shares nothing.
  • Misconception: Events from a portal follow the DOM. Reality: dispatch always walks the internal tree; a click under document.body bubbles to React ancestors whose DOM it never touched.
  • Misconception: Context can't reach into a portal. Reality: context follows the React tree too; portal children read it normally.
  • Misconception: Portal children inherit their parent's CSS. Reality: DOM-wise they're elsewhere, styles scoped to your root element or theme classes may not apply.
  • Misconception: Portals are a modal-only trick. Reality: tooltips, toasts, dropdowns, hovercards, anything that must escape ancestor clipping or stacking.
  • Misconception: State and effects behave oddly inside portals. Reality: completely normal, same tree, same rules.

Why it works this way

  • Physical imprisonment needs a physical fix. No z-index or overflow value can free a node from an ancestor's clipping or stacking context, moving the node is the only robust answer.
  • All React machinery runs on the internal tree, so relocation is free. Events, context, and state never consulted the DOM's shape in the first place; letting the DOM location differ costs React nothing and frees you completely.
  • Event-through-React-tree keeps components self-contained. A modal twelve levels deep can still participate in ancestor handlers; logical containment and visual placement stay independent decisions.
  • A tiny API suffices. The internal tree already models "logical parent ≠ physical parent" everywhere (component boundaries do this constantly), so one function, createPortal, covers every overlay use case.

Try it yourself

  1. Run the click-counter example. Click the overlay background: the modal closes and the counter increments. Inspect the DOM to prove the overlay sits under body, outside the counting div. Now explain both facts in one sentence: "events follow the ___ tree."
  2. Render a non-portal modal inside a wrapper with overflow: hidden and a small fixed height; watch it clip. Switch to createPortal and watch it escape.
  3. Provide a context value in App and read it inside the portal, it works. Then scope some styles under a class on your root div and observe which of them the portal misses.
  4. Move the stopPropagation from the dialog up to the overlay and predict, before testing, which clicks still reach App's counter.

Recap

  • createPortal(children, domNode) renders children under a different DOM node while keeping their place in the React tree.
  • DOM-wise: the node escapes ancestor overflow, transforms, and stacking contexts, the modal problem's real fix.
  • React-wise: state, effects, context, and event bubbling all follow the React tree, untouched.
  • Events always dispatch along the internal tree; portals merely make that visible.
  • Modals, tooltips, toasts, dropdowns: the four classic portal shapes.
  • Your responsibilities: scoped-CSS reach, focus on open / return on close / Tab trapping, aria-modal and labeling, and an existing target node for SSR.
  • A portal is not a second root, same tree, same root, different DOM parent.

Next

Server rendering and hydration →