Portals
A component normally places its host nodes beneath the DOM node of its parent:
function Card() {
return (
<article>
<button>Show details</button>
<aside>Details</aside>
</article>
);
}
That is usually exactly what we want. But overlays expose a mismatch. A dialog may belong to Card's state and context while needing to appear above a clipping container, a transformed ancestor, or the application's other visual layers.
That leaves the next hole:
how can a component keep a child in its React subtree while placing that child's DOM nodes somewhere else?
Trying an ordinary child #
Start with the mechanism we already have. Render an overlay as a normal child of a deliberately clipped panel:
function Card() {
return (
<section
style={{
position: 'relative',
width: 260,
height: 96,
boxSizing: 'border-box',
overflow: 'hidden',
border: '2px solid #64748b',
padding: 12,
}}
>
<strong>Account</strong>
<div
role="dialog"
aria-label="Account details"
style={{
position: 'absolute',
top: 52,
left: 20,
width: 210,
minHeight: 76,
boxSizing: 'border-box',
padding: 12,
background: '#dbeafe',
border: '2px solid #2563eb',
}}
>
This content continues below the panel.
</div>
</section>
);
}
root.render(<Card />);
setTimeout(() => {
const panel = mountNode.querySelector('section');
const dialog = mountNode.querySelector('[role="dialog"]');
console.log('inside the panel:', dialog.parentElement.tagName);
console.log(
'extends below the panel:',
dialog.getBoundingClientRect().bottom > panel.getBoundingClientRect().bottom,
);
}, 20);
Waiting to run
Not run yet.
The panel's overflow: hidden applies to all of its DOM descendants. Raising z-index can reorder overlapping elements within compatible stacking contexts, but it cannot make a descendant escape an ancestor's clipping boundary.
The first attempt reveals two requirements that pull in different directions:
- the dialog should remain a child of
Cardin the component design - the dialog's host nodes should not remain inside the panel in the DOM
Moving the dialog component to the top of the application would fix its DOM placement, but then Card would have to lift the dialog's state and inputs merely to satisfy layout. Creating a second React root in an overlay node would also produce the right DOM location, but it would create a separate React tree rather than preserve this parent-child relationship.[2]
We need to separate two structures that have matched until now.
Naming the two trees #
React already works with a tree of components and elements. The renderer produces a second tree of browser nodes from it:
React tree DOM tree
App #root
└── Card └── article
└── Dialog └── aside
Ordinary rendering makes the ancestry look almost identical, so it is easy to treat the two trees as one. They answer different questions:
| Tree | Determines |
|---|---|
| React tree | component ownership, context, and React event propagation |
| DOM tree | physical containment, layout, clipping, and browser selectors |
The hole asks the DOM renderer to use a different host container for one React child.
Creating a portal #
createPortal takes renderable children and an existing DOM node:[1]
import { createPortal } from 'react-dom';
const portal = createPortal(
<div role="dialog">Account details</div>,
document.getElementById('overlay-root'),
);
It returns a React node. A component can include that node in its render output just like JSX:[1]
function Card() {
return (
<article>
<h2>Account</h2>
{createPortal(<Dialog />, overlayRoot)}
</article>
);
}
The target DOM node must already exist when createPortal runs. Applications commonly provide a stable overlay container beside the application root:
<body>
<div id="root"></div>
<div id="overlay-root"></div>
</body>
Test the same clipped panel with a target outside its React root:
const portalHost = document.createElement('div');
portalHost.dataset.portalHost = '';
mountNode.parentElement.appendChild(portalHost);
function Card() {
return (
<section
style={{
position: 'relative',
width: 260,
height: 70,
overflow: 'hidden',
border: '2px solid #64748b',
padding: 12,
}}
>
<strong>Account</strong>
{createPortal(
<div
role="dialog"
aria-label="Account details"
style={{
marginTop: 8,
padding: 12,
background: '#dbeafe',
border: '2px solid #2563eb',
}}
>
Rendered outside the clipped panel.
</div>,
portalHost,
)}
</section>
);
}
root.render(<Card />);
setTimeout(() => {
const dialog = portalHost.querySelector('[role="dialog"]');
console.log('inside the React mount:', mountNode.contains(dialog));
console.log('inside the portal host:', portalHost.contains(dialog));
}, 20);
Waiting to run
Not run yet.
The dialog is absent from mountNode and present in portalHost. Yet the call to createPortal remains inside Card's returned React tree.
React tree DOM tree
App demo host
└── Card ├── #root
└── Dialog │ └── section
└── portal host
└── div[role=dialog]
This fills the placement hole:
a portal preserves a child's position in the React tree while choosing another container for its host nodes
Testing context across the gap #
The previous chapter established that context follows React-tree ancestry rather than DOM containment. A portal lets us test that distinction directly.
Provide a theme inside the application root, then render the consumer into a sibling DOM container:
const { createContext, useContext } = React;
const ThemeContext = createContext('missing');
const portalHost = document.createElement('div');
mountNode.parentElement.appendChild(portalHost);
function Dialog() {
const theme = useContext(ThemeContext);
return <p>Portal theme: {theme}</p>;
}
function App() {
return (
<ThemeContext value="midnight">
<section>
<p>Application content</p>
{createPortal(<Dialog />, portalHost)}
</section>
</ThemeContext>
);
}
root.render(<App />);
setTimeout(() => {
console.log(portalHost.textContent);
console.log('DOM descendant:', mountNode.contains(portalHost.firstChild));
}, 20);
Waiting to run
Not run yet.
Dialog receives midnight, not the default missing. Its paragraph is not a DOM descendant of the provider's section, but Dialog is still a descendant of the provider in the React tree.[1]
No new root was created for the portal target. The original renderer still owns this child, so its context connections remain intact.
Following an event home #
DOM events normally bubble through DOM ancestors. Consider a click inside the portal: its physical ancestors do not include Card, so will React's onClick on Card run?
const { useState } = React;
const portalHost = document.createElement('div');
mountNode.parentElement.appendChild(portalHost);
function Card() {
const [clicks, setClicks] = useState(0);
return (
<section onClick={() => setClicks((count) => count + 1)}>
<p>Clicks seen by Card: {clicks}</p>
{createPortal(
<button>Click in the portal</button>,
portalHost,
)}
</section>
);
}
root.render(<Card />);
setTimeout(() => {
portalHost.querySelector('button').click();
}, 20);
Waiting to run
Not run yet.
The count becomes one. React events from a portal propagate through React ancestors, even when those components do not correspond to DOM ancestors of the event target.[1]
That behavior is usually useful. An application-level interaction handler can still observe interactions in its portalled descendants. It can also surprise code that reasons only from element.parentElement.
If a particular interaction should not reach a React ancestor, stop it inside the portal:
createPortal(
<div onClick={(event) => event.stopPropagation()}>
<Dialog />
</div>,
overlayRoot,
)
Another option is to render the portal higher in the React tree so its logical ancestry matches the desired event boundary. The important rule is consistent:
DOM placement controls layout; React ancestry controls React context and event propagation
Keeping state with its owner #
A portal is part of ordinary render output, so state still decides whether it exists and which props it receives:
const { useState } = React;
const portalHost = document.createElement('div');
mountNode.parentElement.appendChild(portalHost);
function AccountCard() {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(true)}>Show account</button>
{open && createPortal(
<div role="dialog" aria-modal="true" aria-label="Account">
<p>Account settings</p>
<button onClick={() => setOpen(false)}>Close</button>
</div>,
portalHost,
)}
</section>
);
}
root.render(<AccountCard />);
setTimeout(() => {
mountNode.querySelector('button').click();
setTimeout(() => {
console.log('opened:', Boolean(portalHost.querySelector('[role="dialog"]')));
portalHost.querySelector('button').click();
setTimeout(() => {
console.log('closed:', !portalHost.querySelector('[role="dialog"]'));
}, 20);
}, 20);
}, 20);
Waiting to run
Not run yet.
AccountCard owns the open state. Opening includes the portal node in its next render; closing removes it. React commits the dialog into portalHost, but no separate state channel is needed to control it.
The target should remain stable. Passing a different DOM node on a later render causes React to recreate the portal content in the new target.[1] Query a long-lived node once at module scope, receive it as a prop, or store a target supplied by an external widget after that node becomes available.
The optional third argument gives the portal a key:
createPortal(<Dialog />, overlayRoot, 'account-dialog')
Like other keys, it identifies this returned child among siblings. It does not name or create the destination.
Distinguishing a portal from another root #
Both createPortal(children, domNode) and createRoot(domNode) can place React-managed nodes inside a chosen DOM container. They express different ownership:
| API | Meaning |
|---|---|
createPortal |
this content is a child in an existing React tree, committed into another DOM container |
createRoot |
this container begins an independent React tree |
Use a portal when a component needs to render a modal, tooltip, menu, or other layer outside its normal DOM ancestry. Use another root when an independently mounted React application or island truly needs its own lifecycle.[2]
Portals can also target a DOM node owned by non-React code. For example, a map widget might create a popup container and give that node to a React component. Once the target exists, a portal lets the original React tree supply interactive content to it without pretending the widget's DOM is a new React application.[1]
What a portal does not solve #
Moving host nodes is only the structural part of an overlay. A production modal still needs deliberate behavior:
- an accessible name and appropriate dialog semantics
- focus moved into the dialog and restored when it closes
- keyboard behavior, commonly including Escape
- background interaction and scroll management
- layering styles and a suitable stable target
A portal does not automatically add any of these. It also does not visually escape every possible boundary: rendering into an unsuitable target may still encounter that target's clipping or stacking context.
This boundary keeps the abstraction small. A portal answers where React commits these host nodes, while the dialog component or an accessible component library answers how this overlay behaves.
Filling the hole #
The smallest portal has one React child and one existing DOM target:
import { createPortal } from 'react-dom';
const overlayRoot = document.getElementById('overlay-root');
function Card() {
return (
<article>
<h2>Account</h2>
{createPortal(
<div role="dialog">Account settings</div>,
overlayRoot,
)}
</article>
);
}
Each part has one responsibility:
- the component's returned tree preserves ownership of the child
createPortalmarks a different DOM placement for that child- the existing target provides the physical container where React commits the host nodes
State, props, context, reconciliation, and React events continue through the original React tree. Browser layout and DOM traversal see the portal target instead.
Final definition #
A portal is a React node whose children retain their logical position in one React tree while the renderer commits their host nodes into another existing DOM container.
It separates component ownership from physical DOM containment. The React tree still determines context and React event propagation; the DOM tree determines placement and layout.
Summary #
Portals fill the alternate-placement hole:
- ordinary children become DOM descendants of their rendered parent
- clipping and stacking constraints sometimes make that physical ancestry unsuitable
createPortal(children, domNode, key?)returns a React node- the target DOM node must already exist
- a portal changes DOM placement, not React parentage
- context continues through the original React tree
- React events propagate through React ancestors, even across the DOM gap
- parent state and props control portal content through ordinary rendering
- changing the target DOM node recreates the portal content
createPortalextends an existing React tree;createRootstarts an independent one- portals fit overlays and integration points inside DOM owned by other systems
- a portal provides placement, not complete modal accessibility or interaction behavior