Menu

React Portals: createPortal for Modals and Tooltips

createPortal renders part of a component into a different DOM node, such as document.body, while it stays in the same place in the React tree. Use it for modals, tooltips and menus that must escape overflow hidden and z-index stacking.

This page includes runnable editors - edit, run, and see output instantly.

A portal renders part of a component into a different place in the DOM, usually document.body, while it stays in the same place in the React tree. You create one with createPortal(children, domNode) from react-dom. Portals are how modals, tooltips and dropdown menus escape a parent that has overflow: hidden or its own stacking context.

The box below clips anything that sticks out of it. Open both tips.

The normal tip is cut off at the dashed border. The portal tip appears in full, because its DOM node is a child of <body>, not of the box. Delete overflow: 'hidden' from the box and the normal tip is no longer clipped.

The syntax

import { createPortal } from 'react-dom';

createPortal(children, domNode, key?)
  • children is any JSX: an element, a fragment, a component.
  • domNode is an existing DOM element, such as document.body or document.getElementById('modal-root'). It must exist when the portal renders.
  • key is optional, for when you render a list of portals.

createPortal returns something you put in your JSX like any element. It renders nothing in the parent's own DOM position.

A modal in document.body

A modal is the classic case. Inside a card with a transform, a position: fixed overlay is positioned relative to that card instead of the window, and the card's overflow: hidden then clips it. Rendered into document.body, it covers the whole viewport.

Open the modal, then press Escape or click the dark overlay to close it. Focus moves to the Close button when it opens. Now return the overlay without createPortal (drop the call and its document.body argument): the overlay shrinks to the card's size, because transform makes the card the containing block for fixed elements.

Events bubble through the React tree

A portal changes where the DOM node lives, not where the component lives. React events bubble up the React tree, so a click inside a portal reaches the onClick of the component that rendered it, even though in the DOM the button is a child of <body>.

Native listeners are different. A listener added with addEventListener follows the DOM tree and never sees the click.

Click "Inside the wrapper": both the React handler and the native listener log. Click "In a portal": only the React handler logs. The portal button sits outside the dashed wrapper on screen, yet React still treats it as a child.

This is usually what you want: an onClick on a menu's parent sees clicks on its portal items, and context providers above the component apply inside the portal too. It can surprise you with "click outside to close" logic. An onClick on a wrapper that closes a menu also fires for clicks inside the menu's portal, so stop propagation inside the menu (e.stopPropagation(), covered on the events page), or use a native document listener and check whether the target is inside the portal's DOM node.

Portals into your own container

document.body is the simplest target. Some apps add a dedicated container to index.html so all overlays share one place and one stacking order:

<body>
    <div id="root"></div>
    <div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));

With server rendering (Next.js and other frameworks), document does not exist on the server. Render the portal only after the component has mounted, for example behind a mounted state that an effect sets to true.

When the portal needs to be positioned next to its trigger, measure the trigger first. The first example measures it in the click handler; for a tooltip that must not flicker, measure in useLayoutEffect, which runs before the browser paints.

Accessible modals

Moving the markup into document.body does nothing for keyboard and screen reader users by itself. A modal also needs:

  • role="dialog" and aria-modal="true", with aria-labelledby pointing at its title.
  • Focus moved into the dialog when it opens (the example focuses Close), and back to the button that opened it when it closes.
  • Escape to close.
  • Focus kept inside while it is open, so Tab does not walk into the page behind. Setting the inert attribute on the app root while the modal is open blocks focus and clicks there.

The native <dialog> element handles most of this for you. Opened with dialogRef.current.showModal(), it is drawn in the browser's top layer above every z-index, makes the rest of the page inert, and closes on Escape. It does not need a portal, so it is a good default for simple confirmation dialogs; portals remain the tool for tooltips, menus and custom overlays.

Why z-index alone is not enough

Developers often reach for a portal after a large z-index fails to put a menu above the rest of the page. The reason is stacking contexts. An element with position and a z-index, an opacity below 1, a transform, a filter or isolation: isolate starts a new stacking context, and its children's z-index values only compete with each other inside it. A child with z-index: 9999 inside a card with z-index: 1 still sits below a sibling card with z-index: 2.

A portal moves the element out of every such ancestor. As a child of <body>, its z-index is compared with the top-level elements of the page, so a modest value such as 1000 is enough for overlays.

Common mistakes

Portaling into a node that does not exist yet. document.getElementById('modal-root') returns null if the element is missing, and createPortal throws "Target container is not a DOM element". Check the HTML, or portal into document.body.

Creating the target node during render. Writing document.createElement('div') in the component body makes a new node on every render. Create it once in an effect, or use a fixed container.

Popups that do not follow their trigger. A tooltip positioned from getBoundingClientRect() does not follow its trigger when the page scrolls or resizes. Recompute on scroll and resize (and remove those listeners in the effect's cleanup), or close the tooltip when the page scrolls.

Frequently Asked Questions

What is a portal in React?

A way to render children into a DOM node outside the parent component's DOM element. You create one with createPortal(children, domNode) from react-dom. The children keep their place in the React tree, so props, state and context work as usual.

When should I use a portal?

When something must appear above or outside its container: modals, tooltips, dropdown menus, toasts. A parent with overflow: hidden, a transform or its own stacking context would otherwise clip or hide it.

Do events bubble out of a portal?

Yes, through the React tree. A click inside a portal reaches onClick handlers on the React parents, even though the DOM node lives in document.body. Native listeners added with addEventListener follow the DOM tree instead.

Does context work inside a portal?

Yes. Context, like events, follows the React tree. A modal rendered into document.body still reads the theme or user from providers above the component that created it.

Do I need a portal for a modal?

Not always. The native <dialog> element opened with showModal() is drawn in the browser's top layer, above every z-index, with focus handling built in. A portal is the usual choice for custom modals and for tooltips and menus.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED