Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~25 min· topic 5 of 6

Topic 8.5

Portals

In one line

createPortal(children, domNode) renders part of your component's output into a different DOM element, such as document.body, while it stays in the same place in the React tree: props, context and even events still flow as if it were rendered in place.

Think of it like this

A shop window display. The shop owns the display and decides what goes in it, but the display sits out on the street, not inside the shop. A portal is a display window for a component: the component still owns and controls its content, but the content appears somewhere else on the page.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Portal
Content a component renders into a different DOM element, while it stays in the component's place in the React tree.
createPortal
The react-dom function that makes a portal: createPortal(children, domNode).
Stacking context
A CSS group that decides what draws on top of what. A z-index only competes inside its group.
Modal
A dialog that blocks the rest of the page until you close it.
Toast
A small, short-lived message that pops up in a corner, like "Saved".
Event bubbling
An event first fires on the element you clicked, then on each ancestor in turn.
Focus trap
Keeping keyboard focus inside a modal while it's open, so Tab doesn't wander behind it.
Top layer
A browser layer above all other content, used by <dialog> opened with showModal() and by popovers.

Step by step

01The problem: a box that clips its children

A card has overflow: hidden (to round its corners) and a small z-index. A menu or modal rendered inside it is cut off or covered by the next card. CSS can't let a child escape an ancestor's clipping.

02Render the content somewhere else

Return createPortal(jsx, document.body). The DOM nodes are created inside <body>, outside every card, so nothing clips them.

Modal.jsxwhole filejsx
import { createPortal } from "react-dom";

function Modal({ children }) {
  return createPortal(
    <div className="backdrop">
      <div className="modal" role="dialog" aria-modal="true">{children}</div>
    </div>,
    document.body,
  );
}

03React still sees it in place

In the React tree the modal is a child of the card that rendered it: it gets props from the card, reads context from above the card, and its events bubble up to the card's handlers.

React still sees it in placediagram
Rendering diagram…

04Events follow the React tree

A click on a button inside the modal fires the button's onClick, then onClick on the Modal's React parents, including the card, although the card's DOM node doesn't contain the modal. Call e.stopPropagation() in the portal when that's unwanted.

Events follow the React treediagram
Rendering diagram…

05Make it accessible

On open: move focus to the dialog (or its first field) with a ref. While open: Escape closes, Tab stays inside. On close: focus goes back to the opener, kept in a ref. Label the dialog and mark it modal.

Modal.jsxwhole filejsx
useEffect(() => {
  const opener = document.activeElement; // remember who opened us
  dialogRef.current.focus();
  return () => opener?.focus();          // give focus back on close
}, []);

Try it yourself

  1. 1

    Remove the portal

    In the modal example, replace createPortal(..., document.body) with just the JSX. Open the modal. Predict what you'll see. (The backdrop is position: fixed, but it is trapped inside the card's stacking context and clipped by its overflow: hidden, so it doesn't cover the page properly.)

  2. 2

    Stop the bubbling

    In the events example, wrap the portal button's click with onClick={(e) => e.stopPropagation()}. Which log line disappears?

  3. 3

    Close on backdrop click

    In the modal example, remove the stopPropagation from the dialog. Click the text inside the modal. Why does it close now? (The click bubbles from the dialog to the backdrop's onClick.)

Code & diagrams

A modal that escapes a clipping card New tab

The modal's DOM sits in `<body>`, outside the clipping card, so it covers the page. Focus moves into the dialog on open, Escape closes it, and the effect's cleanup returns focus to the button.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

dialog lives in: <body>
focus is on: <div.modal>
after Escape, dialog open: false
focus is back on: <button#open>
Events bubble through the React tree, not the DOM tree New tab

The normal button's click reaches both listeners. The portal button lives in `<body>`, so the native listener on `#root` never hears it, but the React `onClick` on the card still does: React bubbles events through its own tree.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

portal button's DOM parent: <body>
native listener on #root: click on inside
React parent onClick: click on inside
React parent onClick: click on portaled
Toasts and a tooltip in their own layer New tab

Toasts render into a second div that is a live region, so screen readers announce them. Hover Save: the tooltip is measured in a layout effect and portaled to `<body>`, so the clipping box can't cut it off.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

toasts: [ 'Saved draft 1', 'Saved draft 2' ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Portal into an element that doesn't exist yet

In the toast example, change the target to document.getElementById("notifications"), which isn't in the HTML.

terminal
$ Console
── what you'll see ──
Uncaught Error: Target container is not a DOM element.
An error occurred in the <App> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

Break #2

A click in the modal triggers the card

Give the card in the modal example onClick={() => console.log("card clicked")} and click the modal's Close button.

terminal
$ Console
── what you'll see ──
card clicked

Myth vs fact

Myth

A portal is a separate React app.

Fact

It's part of the same tree: same state, props, context and event bubbling. Only the DOM location changes. A separate createRoot would be a separate app with its own context.

Myth

Portals fix accessibility for modals.

Fact

They fix clipping and stacking only. Focus management, Escape, labelling and focus trapping are still your job (or use <dialog> with showModal()).

Myth

Events from a portal can't reach the parent component.

Fact

They do: React bubbles them through the React tree. Native DOM listeners are the ones that don't see them.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Since React 17, React attaches its event listeners to the root container, and also to each portal's container, so React events from portal content are caught and dispatched along the fiber tree. That's why the React onClick above runs and the native listener on #root doesn't.

  • ▸

    Context flows into portals, so a modal reads the theme or current user from providers above the component that rendered it, even though its DOM is outside the app root. A separate root (another createRoot) wouldn't.

  • ▸

    <dialog> with showModal() puts the element in the browser's top layer, adds a ::backdrop, makes the rest of the page inert and handles Escape. Combined with a ref and an effect that calls showModal()/close(), it often replaces a portal-based modal. The Popover API (popover attribute) does the same for menus and tooltips.

  • ▸

    With server rendering, document doesn't exist on the server, so portals into document.body must only render on the client (for example after an effect sets mounted to true). Phase 20 covers this.

Remember this

  1. 1

    Why you need it: some UI must escape its parent's box. A modal dialog, a tooltip, a dropdown menu or a toast notification rendered deep inside a card can be cut off by the card's overflow: hidden, or hidden behind other elements because of the card's z-index and stacking context (see the CSS course, /css, on positioning). Putting the element at the end of document.body frees it from those boxes.

  2. 2

    createPortal(children, domNode, key?) comes from react-dom. You return it from a component like any JSX: return createPortal(<div className="modal">…</div>, document.body). children is what to render, domNode is an existing DOM element to render into (it must exist already), and key is optional, for lists of portals.

  3. 3

    Two trees. In the DOM tree the portal's content is a child of domNode. In the React tree it is still a child of the component that rendered it. Everything React does follows the React tree: the component receives props from its parent, reads context from providers above it, and its state lives where it always did.

  4. 4

    Events bubble through the React tree. A click inside a portal triggers onClick handlers on React ancestors, even though in the DOM those ancestors don't contain the portal's element. This is useful (a parent can listen to everything inside) and surprising (a click inside a modal reaches an onClick on the card that opened it). Native listeners added with addEventListener follow the DOM tree as usual, so they don't see it. Use e.stopPropagation() inside the portal if a parent must not get the event.

  5. 5

    Accessibility. Moving a modal to the end of body changes reading order for screen readers, so you must manage it: give it role="dialog", aria-modal="true" and a label (aria-labelledby); move focus into it when it opens; close it with Escape; keep Tab inside it while open (a focus trap); and return focus to the button that opened it when it closes. The native <dialog> element with showModal() does much of this for you and renders in the browser's top layer, so it may not need a portal at all (see the HTML course, /html). Toasts should be announced with a live region (role="status").

  6. 6

    The target can be any element: document.body, or a dedicated <div id="modal-root"> placed after your app's root in index.html, which keeps portals in a predictable place. When the component that rendered the portal unmounts, React removes the portal's content too.

Explain it without notes

01

What is a portal and when would you use one?

02

A button inside a portal is clicked. Which onClick handlers run: those of its DOM ancestors or its React ancestors? Why?

03

What must you do to make a portal-based modal accessible?

04

What is the difference between a portal and a second createRoot?

Practice

01

Write a Toast component that portals a message into document.body and removes itself after 2 seconds.

02

Write a dropdown menu inside an overflow: hidden box whose list is portaled to document.body and positioned under the button.

Trade-offs

  • ↔

    Portals solve clipping and stacking but separate DOM order from visual order, so you must manage focus and reading order yourself.

  • ↔

    React-tree event bubbling lets parents observe portal content, but surprises code that assumes DOM ancestry, such as "click outside" detectors that use contains().

  • ↔

    For modals and popovers, the native <dialog> and Popover API render in the top layer without a portal and bring keyboard behaviour for free; a portal gives you full styling control and works the same everywhere.

Done when you can

  • I can render content into document.body or another element with createPortal.

  • I can explain why modals, tooltips and toasts are often portaled.

  • I know events from a portal bubble to React ancestors, not DOM ancestors.

  • I can move focus into a modal, close it with Escape and return focus.

  • I know a portal shares props, state and context with its parent.