skip to content

Portals

Portals render children into a different DOM node while keeping them in the same React tree. Interviewers like this one because the answer has two halves: the layout reason (escaping overflow and z-index) and the surprising part (events still bubble to the React parent).

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In React, what does createPortal(children, domNode) from react-dom do, and what layout problem does it solve for a modal or dropdown?

level: juniorimportance: should knowfreq 55%

answer

  1. logical parent versus physical parent
  2. clipping and stacking contexts
  3. imported from react-dom
  4. you name the target element
  5. one React tree, two DOM positions

basics

~20 s

createPortal renders children into any DOM element you name while keeping them in the same React tree. A modal can therefore escape an ancestor's overflow clipping and z-index stacking context without moving the component that owns its state.

solid answer

~50 s

`createPortal(children, domNode)` is exported from `react-dom`. It returns a React node you render like normal JSX, but React inserts the resulting DOM into the element you pass instead of into the component's usual DOM parent. The point is that only the *DOM* position changes: the children stay in the same React tree, so they still receive props and context from the component that rendered them, still update its state, and are still covered by the same error boundary. That matters because CSS makes overlays hard from deep in the tree — an ancestor with `overflow: hidden` clips a dropdown no matter what `z-index` you give it, and an ancestor with `transform` or `opacity` creates a stacking context the child can never paint above. Portalling the overlay into `document.body` or a dedicated `#modal-root` sidesteps both while the component keeps ownership of the behaviour.

code

javascript · 20 lines
javascript
import { useState } from 'react';
import { createPortal } from 'react-dom';

export default function Card() {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ overflow: 'hidden', height: 120, position: 'relative', zIndex: 0 }}>
      <button onClick={() => setOpen(true)}>Open menu</button>
      {open &&
        createPortal(
          <ul className="menu" onClick={() => setOpen(false)}>
            <li>Rename</li>
            <li>Delete</li>
          </ul>,
          document.body,
        )}
    </div>
  );
}

go deeper

for a junior

Be able to say what createPortal does in one sentence — renders children into a different DOM node, same React tree — and give the modal-escaping-overflow example. Know it comes from react-dom.

for a middle

Explain the CSS mechanics: clipping by overflow and stacking contexts created by transform, opacity or z-index, and why neither is solvable with z-index alone. Say which behaviours follow the React tree and which follow the DOM tree.

for a senior

Show that you know a portal only moves DOM: focus trapping, scroll locking, Escape handling and dialog roles are still yours to build, and CSS inheritance and selectors change under you. Be ready to say when you would skip the portal.

for a principal

Own the policy question: one shared portal root inside the themed wrapper versus ad-hoc document.body targets, how overlays from different components order against each other, and when the platform top layer replaces portals in your component library.

## The problem portals solve A modal, dropdown, tooltip or toast belongs *logically* to the component that owns it: the state that opens it, the props it needs, the callbacks it fires are all right there. But visually it has to sit on top of everything and must never be cut off. Two independent CSS mechanisms make that impossible from deep inside the tree. **Clipping.** Any ancestor with `overflow: hidden`, `auto` or `scroll` cuts its descendants off at its own box. This has nothing to do with painting order, so no `z-index` value rescues you — a dropdown inside a scrollable card is simply invisible past the card's edge. **Stacking contexts.** An element creates a new stacking context when it is positioned with a `z-index`, or when it has `transform`, `filter`, `opacity` below 1, `will-change`, or `contain: paint`. Everything inside is painted as one unit within that context, so a child with `z-index: 9999` still cannot rise above a *sibling* of that ancestor. This is what the endless "z-index war" in stylesheets actually is. Moving the markup up the DOM by hand fixes both, but then the component that owns the state no longer owns the markup. A portal gives you the DOM move without the ownership move. ## The API ```js import { createPortal } from 'react-dom'; createPortal(children, domNode, key); ``` It is exported from `react-dom`, not from `react`. `children` is any renderable React content, `domNode` is an existing DOM element, and the optional third argument is a `key`, used when you render several portals from a list. The call returns a React node, so you embed it in JSX or return it directly: ```jsx function Modal({ children }) { return createPortal(<div className="modal">{children}</div>, document.body); } ``` React appends the rendered nodes into `domNode` on mount and removes them on unmount. You never call `appendChild` or clean up yourself, and conditional rendering works exactly as elsewhere: `{open && createPortal(...)}` mounts and unmounts the portal content. ## Two trees at once The mental model that makes everything else predictable: portalled content has **one React parent and a different DOM parent**. Following the *React* tree: props, context from providers above the component, state updates, error boundaries, Suspense boundaries, and React's own event propagation. A portalled child of a themed provider still reads that context. Following the *DOM* tree: CSS inheritance and descendant selectors, inherited custom properties, `element.contains()` and `closest()`, native event bubbling, and screen-reader reading order. A rule written as `.card .menu { … }` stops matching once `.menu` is portalled out of `.card`, and a `--brand` custom property defined on an app wrapper is no longer inherited if you portal to `document.body`. ## Practical notes The container must already exist when the portal renders. `document.body` always does. A custom `#modal-root` must be present in the HTML document; if it is created later, keep it in state and render the portal only once you have it. A portal moves DOM and nothing else. It does not trap focus, does not lock background scrolling, does not add `role="dialog"` or `aria-modal`, and does not close on Escape. Those remain your job, and a dialog without them is a real accessibility defect rather than a styling detail. Not every overlay needs one. If nothing between the component and the root clips or creates a stacking context, plain in-place rendering is simpler and keeps CSS scoping intact. The platform also offers escapes now — `<dialog>` opened with `showModal()` and the HTML `popover` attribute both promote an element to the browser's top layer — so a portal is one tool among several rather than the automatic answer. ## What interviewers listen for The strong answer names both halves: the DOM node moves, the React relationship does not. Candidates who describe a portal as "rendering a second React app into another element" have the wrong model and will get every follow-up wrong — especially the one about events, where the React relationship is exactly what decides the behaviour.

  • Does a component rendered through a portal still receive context from providers above it?
    Yes. Context lookup walks the React tree, and portal children remain React children of the component that rendered them. A theme, locale or auth provider wrapping that component still reaches the portalled content, even though the DOM nodes are attached to `document.body`. The same is true of error boundaries and Suspense boundaries — they are React-tree relationships, not DOM ones.
  • What breaks in your CSS when you move an overlay into a portal?
    Anything that depends on DOM ancestry. Descendant selectors like `.card .menu` stop matching, inherited properties such as `font-family`, `color` and CSS custom properties now come from the portal container instead of the old parent, and a theme class applied to an app wrapper no longer applies. The usual fix is to put the portal root inside the themed wrapper, or to give the portalled element its own self-contained class.
  • When would you not use a portal for an overlay?
    When nothing between the component and the root clips it or creates a stacking context — then in-place rendering is simpler and keeps CSS scoping and reading order intact. Also when the platform primitives fit: `<dialog>` with `showModal()` or the HTML `popover` attribute put the element in the browser's top layer without moving it in your React tree at all.

Like a stage actor whose voice comes through a speaker at the back of the hall: the sound arrives from a different place, but the actor is still part of the same performance.

saying these in an interview costs you the question

  • Says a portal creates a second React root
  • Thinks a bigger z-index fixes overflow clipping
  • Claims portalled children lose their context providers
  • Says you must append and remove the node yourself
  • Imports createPortal from 'react' instead of 'react-dom'

context

open as a page

A React component renders a modal with createPortal into document.body, yet clicks inside that modal still trigger an onClick handler on the component's own wrapper element. Why does the event reach a component that is not a DOM ancestor?

level: middleimportance: should knowfreq 48%

basics

~20 s

React propagates its events along the React element tree, not the document tree. Portal children are still React children of the component that rendered them, so onClick handlers on React ancestors run even though the DOM nodes live under document.body.

open as a page

A dropdown closes on outside clicks using a document listener that ignores clicks where wrapperRef.current.contains(event.target). After the menu is moved into createPortal(menu, document.body), it now closes the instant you click an item inside it. What changed, and how do you fix it?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Node.contains walks the DOM, and the portalled menu is no longer a DOM descendant of the wrapper, so inside clicks now look outside. Fix it by testing containment against both the trigger element and the portalled content, each with its own ref.

open as a page

A design system's Modal, Tooltip, Select and Toast components all call createPortal(content, document.body). As the library owner, what would you standardize about the portal target, and what does portalling cost the consumers of those components?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Standardize on one configurable portal root that lives inside the app's theming wrapper, injected through context so consumers can retarget it. The costs are lost CSS inheritance and scoping, global stacking order between overlays, reading order and focus that no longer match the visual layout, and no server-rendered markup.

open as a page