Must knowAdvanced React APIs · 03

createPortal

Render a dialog into document.body so a clipping parent can’t hide it; events and context still follow the React tree.

On this page · 7 sections
  1. In one minute
  2. The example: “Quick view”
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

In place
Card.tsx
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'))
What you see
Quick view dialog: closed
Handlers that ran: –
  1. render phase{open && <QuickView onClose={() => setOpen(false)} />}Click Quick view. The dialog renders in place, so its DOM goes inside the card.
  2. browser<article className="clip-card" // overflow: hiddenThe card clips everything outside its box. (The dialog is position: fixed, but the card’s transform makes the card its containing block.) Recorded: the Close button wasn’t visible.
  3. eventonClick={() => log('card onClick ran')}>Click Close: the click bubbles through the DOM and through React, so both the plain listener and React’s onClick run.

0 / 0
A product card has a “Quick view” dialog. The card has 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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react-dom/createPortal