In one minute
createPortal(children, domNode) renders children into a different DOM node, usually document.body, while in React they stay exactly where you wrote them. Only the DOM placement changes. The portaled content still reads the same context, and its events still bubble through the React tree to the component that rendered it. Use it for dialogs, tooltips, dropdown menus and toasts: anything that must escape a parent’s overflow: hidden, z-index or transform.
You’ll be able to: render a dialog that no parent can clip, and predict which click handlers run inside a portal.
1function Card() {2 const [open, setOpen] = useState(false)3 return (4 <article className="clip-card" // overflow: hidden5 onClick={() => log('card onClick ran')}>6 <button onClick={() => setOpen(true)}>Quick view</button>7 {open && <QuickView onClose={() => setOpen(false)} />}8 </article>9 )10}11// plus, for comparison, a plain DOM listener on the card:12card.addEventListener('click', () => log('card DOM listener ran'))- render phase
{open && <QuickView onClose={() => setOpen(false)} />}Click Quick view. The dialog renders in place, so its DOM goes inside the card. - browser
<article className="clip-card" // overflow: hiddenThe card clips everything outside its box. (The dialog isposition: fixed, but the card’stransformmakes the card its containing block.) Recorded: the Close button wasn’t visible. - event
onClick={() => log('card onClick ran')}>Click Close: the click bubbles through the DOM and through React, so both the plain listener and React’sonClickrun.
1function Card() {2 const [open, setOpen] = useState(false)3 return (4 <article className="clip-card" // overflow: hidden5 onClick={() => log('card onClick ran')}>6 <button onClick={() => setOpen(true)}>Quick view</button>7 {open && createPortal(<QuickView … />, document.body)}8 </article>9 )10}11// plus, for comparison, a plain DOM listener on the card:12card.addEventListener('click', () => log('card DOM listener ran'))- render phase
{open && createPortal(<QuickView … />, document.body)}Click Quick view. Same component, but its DOM goes straight into<body>. In the React tree it’s still the card’s child. - browser
{open && createPortal(<QuickView … />, document.body)}Nothing clips it. Recorded: parentbody, Close button visible. It also still reads the card’s context: the dialog got thedarktheme class. - event
onClick={() => log('card onClick ran')}>Click Close. React events follow the React tree, so the card’sonClickstill runs. The DOM listener doesn’t: in the DOM, the dialog isn’t inside the card.
overflow: hidden and a hover transform (both versions recorded).The example: “Quick view”
A product card has a “Quick view” button that opens a dialog with more details. The card has overflow: hidden (for its rounded image corners) and a small hover transform.
function QuickView({ onClose }: { onClose: () => void }) {
const theme = use(ThemeContext)
return (
<div className={`quick-view ${theme}`} role="dialog" aria-label="Quick view">
<h2>Trail Backpack</h2>
<p>28 L · water resistant · $64.00</p>
<button id="close" onClick={onClose}>Close</button>
</div>
)
}
1. Rendered in place
function CardInline() {
const [open, setOpen] = useState(false)
return (
<article className="clip-card" onClick={() => log('card onClick ran')}>
<h2>Trail Backpack</h2>
<button id="open" onClick={() => setOpen(true)}>Quick view</button>
{open && <QuickView onClose={() => setOpen(false)} />}
</article>
)
}
Recorded after opening it:
{
"parent": "article.clip-card",
"closeButtonVisible": false,
"card": {
"bottom": 160,
"right": 290
},
"close": {
"top": 200,
"left": 103
},
"themeClass": "quick-view dark"
}
The dialog is position: fixed, which would normally place it relative to the viewport. But a transform on an ancestor makes that ancestor the containing block for fixed children, so the dialog was positioned inside the card and clipped by overflow: hidden. The Close button at top: 200 sits below the card’s bottom edge (160), and nothing visible was under it.
2. Rendered through a portal
function CardPortal() {
const [open, setOpen] = useState(false)
return (
<article className="clip-card" onClick={() => log('card onClick ran')}>
<h2>Trail Backpack</h2>
<button id="open" onClick={() => setOpen(true)}>Quick view</button>
{open && createPortal(<QuickView onClose={() => setOpen(false)} />, document.body)}
</article>
)
}
{
"parent": "body",
"closeButtonVisible": true,
"card": {
"bottom": 160,
"right": 290
},
"close": {
"top": 177,
"left": 78
},
"themeClass": "quick-view dark"
}
Its DOM parent is now body, so nothing clips it, and it still has the dark theme class from the context the card is inside.
3. Where does a click go?
The card has a React onClick, and the lesson also adds a plain DOM listener to the card for comparison. Clicking Close, in place:
card DOM listener ran
card onClick ran
Through the portal:
card onClick ran
React’s onClick on the card ran in both cases, because the dialog is the card’s child in the React tree. The DOM listener ran only when the dialog was really inside the card in the DOM.
How it works
- React tree vs DOM tree. A portal is a fiber like any other, so context, state, effects and error boundaries all follow the React tree. Only the host DOM nodes are attached under
domNodeinstead of the nearest parent DOM node. - Events. React listens at the root container and dispatches events along the fiber tree (How React Works, Start to Finish). That’s why
onClickon an ancestor component fires for clicks inside its portal. Native listeners follow the DOM. If you need a click inside a dialog not to reach the card’sonClick, calle.stopPropagation()in the dialog. - The target node must exist when the portal renders.
document.bodyalways does; a custom#modal-rootmust be in the HTML before React renders into it. - Accessibility is still your job. A portal moves DOM; it doesn’t add focus trapping,
aria-modalor Escape handling. The native<dialog>element withshowModal()gives you the top layer, focus handling and Escape, and is worth considering for modals.
Common mistakes
- Expecting the portal to stop React events. Clicks inside it still trigger the card’s
onClick(recorded). - Making the dialog
position: fixedand expecting that to be enough. It isn’t when an ancestor has atransform,filterorcontain(recorded: clipped). - Portaling to a node that isn’t in the document yet.
- Forgetting focus. When a dialog opens, move focus into it, and return focus to the trigger when it closes.
Interview Q&A
What does createPortal do?
createPortal(children, domNode) renders children’s DOM into domNode instead of the parent component’s DOM. In React they’re still the parent’s children.
Why use a portal instead of position: fixed and a big z-index?
Ancestors can still trap the element. overflow: hidden clips it, z-index only works within the ancestor’s stacking context, and a transform makes the ancestor the containing block even for position: fixed. Recorded: the in-place dialog was clipped by the card; the portaled one wasn’t.
Do events inside a portal bubble to the parent component?
Yes. React events follow the React tree, not the DOM tree. Recorded: clicking Close in the portaled dialog ran the card’s React onClick, but not a plain DOM listener on the card.
Does portaled content see the parent's context?
Yes. Context comes from the React tree. Recorded: the portaled dialog got the card’s dark theme.
Name common uses.
Modals, tooltips and popovers, dropdown menus inside scrolling containers, toasts, and rendering into a DOM node owned by a non-React part of the page.
Related
- Context with use: context passes through portals.
- useLayoutEffect: positioning a tooltip, which is often portaled.
- How React Works, Start to Finish: how React dispatches events from the root.
Sources
- react.dev:
createPortal - MDN: Containing block (why a
transformtrapsposition: fixed),<dialog> - Topic order inspired by Kent C. Dodds’ EpicReact Advanced React APIs workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react-dom/createPortal