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
overflowand 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:
- 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 itsz-index. - React-wise, nothing changes. In the internal tree, the portal's children sit exactly where you wrote them, inside
Modal, inside whatever renderedModal. 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 normaldomContainer: 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:
- 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. - 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. - React dispatches along that path (two chapters ago): the overlay's
onClickfires (closing the modal), then App's divonClickfires, the counter increments, courtesy of a click on a DOM node that is not inside the div that counted it. - 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:
- Open:
setOpen(true)mountsModal; its DOM is built in memory and inserted underdocument.body, the first-render pipeline from chapter 1, miniaturized to one subtree. - 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. - Overlay click: the overlay's own
onClickcloses the modal; the dialog'sstopPropagationkeeps clicks inside the dialog from counting as overlay clicks. - Escape: a plain document listener in an effect, removed by the cleanup the moment the modal unmounts.
- Accessibility:
role="dialog"plusaria-modal="true"tells assistive technology that the rest of the page is inert while this is open. - 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 (amountedflag 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 (PNG hi-res):
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.bodybubbles 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-indexoroverflowvalue 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
- 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." - Render a non-portal modal inside a wrapper with
overflow: hiddenand a small fixed height; watch it clip. Switch tocreatePortaland watch it escape. - Provide a context value in
Appand 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. - Move the
stopPropagationfrom 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-modaland labeling, and an existing target node for SSR. - A portal is not a second root, same tree, same root, different DOM parent.