createPortal
Renders children into a different DOM node while keeping them in the same React tree.
createPortal(children, domNode, key?)- Added in React 16.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$children | ReactNode | Anything React can render: JSX, a Fragment, a string, a number, or an array of these. |
$domNode | Element | DocumentFragment | An existing DOM node, such as one returned by document.getElementById(). Passing a different node on an update recreates the portal content. |
$keyoptional | string | number | A unique key for the portal, used the same way as a key on any other React node. |
Return value
ReactNodeA React node you can include in JSX or return from a component; when React encounters it in the render output it places `children` inside `domNode`.
Errors
React throws "Target container is not a DOM element." when domNode is null, undefined, or not a valid DOM container.
Changelog
- PHP 17.0.0React attaches its event listeners to the root container instead of document, so document-level native listeners now run after React's handlers for events originating in a portal.
createPortal renders children into a DOM node somewhere else in the document while keeping them in the same React tree. Context still flows in, state survives, and events still bubble to the React parent. Only the physical placement changes.
When to reach for it
- A modal, dropdown, tooltip, or toast that must escape an ancestor's
overflow: hidden,transform, orz-indexstacking context. - Rendering React output into a DOM node owned by something else: a map library's popup, a legacy widget, a slot in a server-rendered page shell.
What trips people up
Events propagate along the React tree, not the DOM tree. A click inside a portal fires the onClick of the component that rendered the portal, even though the node lives under document.body. Meanwhile a native addEventListener on an ancestor DOM node never sees it, and a document-level "click outside to close" listener does. That combination is the source of most portal bug reports.
The target node must already exist when React renders. There is no document during server rendering, so look up or create the node in an Effect rather than during render. Swapping domNode for a different node on a later render tears down and rebuilds the content, losing scroll position, focus, and uncontrolled input values.
A portal moves DOM, not accessibility. Focus trapping, aria-modal, and returning focus on close remain your job.
Example
When to use it
- Render a dialog at the end of document.body so a parent's overflow: hidden cannot clip it.
- Show a tooltip that must paint above every stacking context on the page.
- Mount React UI inside a container element created by a non-React map or editor library.
- Fill a slot in a server-rendered page shell that sits outside the React root.
Frequently asked questions
Why do clicks inside my portal still trigger the parent's onClick?
Because React dispatches events along the React tree, not the DOM tree, so a portal's events bubble to the component that rendered it. Call event.stopPropagation() inside the portal, or render the portal higher in the React tree.
How do I use createPortal with server-side rendering?
Do not call it during render on the server — there is no document. Look up or create the target node inside an Effect and render null until it exists, so the server and the first client render match.
Does a portal break React context or state?
No. A portal only changes where the DOM nodes are inserted; context from the parent tree still reaches the children, and their state is preserved across re-renders as long as the target node stays the same.
More examples
Portal into a container that already exists in the HTML
The dialog markup is appended inside #modal-root, a sibling of the React root, so nothing in the app's layout can clip or restack it. The Escape handler is registered on document because the dialog element itself may not hold focus. Guarding on a missing container keeps the component safe during server rendering, where document.getElementById does not exist.
import { useEffect } from "react";
import { createPortal } from "react-dom";
// index.html contains:
// <div id="root"></div>
// <div id="modal-root"></div>
function Modal({ title, onClose, children }) {
useEffect(() => {
function onKeyDown(event) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]);
// There is no `document` at all on the server, so test for it before
// touching it: reading document.getElementById during render would throw.
const container =
typeof document === "undefined"
? null
: document.getElementById("modal-root");
if (!container) return null;
return createPortal(
<div role="dialog" aria-modal="true" aria-label={title}>
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>,
container
);
}
export default function Page() {
return (
<main>
<h1>Settings</h1>
<Modal title="Confirm" onClose={() => {}}>
<p>Delete this project?</p>
</Modal>
</main>
);
}Stopping portal events from reaching the React parent
Without the stopPropagation call, choosing a menu item would also increment "Row clicks", because React dispatches the event up the React tree even though the <ul> lives under document.body. With it, only "Picked" changes. This is the fix for the classic "my row selected itself when I clicked the menu" bug.
import { useState } from "react";
import { createPortal } from "react-dom";
function Menu({ items, onPick }) {
return createPortal(
<ul onClick={(event) => event.stopPropagation()}>
{items.map((item) => (
<li key={item}>
<button onClick={() => onPick(item)}>{item}</button>
</li>
))}
</ul>,
document.body
);
}
export default function Toolbar() {
const [picked, setPicked] = useState("none");
const [rowClicks, setRowClicks] = useState(0);
return (
<div onClick={() => setRowClicks((n) => n + 1)}>
<p>Row clicks: {rowClicks}</p>
<p>Picked: {picked}</p>
<Menu items={["Rename", "Duplicate", "Delete"]} onPick={setPicked} />
</div>
);
}Rendering into a DOM node owned by another library
The panel element is looked up after mount, so React never renders against a node that does not exist yet. Until the effect runs, the component renders nothing; on the second render the portal attaches. Because the lookup returns the same element on every call, the portal content is never torn down and rebuilt.
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
// A third-party widget hands you a plain DOM element to fill in.
function WidgetPanel({ getPanelElement, children }) {
const [panel, setPanel] = useState(null);
useEffect(() => {
setPanel(getPanelElement());
}, [getPanelElement]);
if (!panel) return null;
return createPortal(children, panel);
}
export default function Dashboard() {
const getPanelElement = () => document.querySelector(".vendor-panel");
return (
<section>
<h1>Dashboard</h1>
<WidgetPanel getPanelElement={getPanelElement}>
<p>Rendered by React, hosted by the vendor widget.</p>
</WidgetPanel>
</section>
);
}
Discussion