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-domfunction that makes a portal:createPortal(children, domNode). - Stacking context
- A CSS group that decides what draws on top of what. A
z-indexonly 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 withshowModal()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.
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.
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.
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.
useEffect(() => {
const opener = document.activeElement; // remember who opened us
dialogRef.current.focus();
return () => opener?.focus(); // give focus back on close
}, []);Try it yourself
- 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 isposition: fixed, but it is trapped inside the card's stacking context and clipped by itsoverflow: hidden, so it doesn't cover the page properly.) - 2
Stop the bubbling
In the events example, wrap the portal button's click with
onClick={(e) => e.stopPropagation()}. Which log line disappears? - 3
Close on backdrop click
In the modal example, remove the
stopPropagationfrom the dialog. Click the text inside the modal. Why does it close now? (The click bubbles from the dialog to the backdrop'sonClick.)
Code & diagrams
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
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>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
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 portaledToasts 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
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.
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.
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
onClickabove runs and the native listener on#rootdoesn'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>withshowModal()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 callsshowModal()/close(), it often replaces a portal-based modal. The Popover API (popoverattribute) does the same for menus and tooltips. - ▸
With server rendering,
documentdoesn't exist on the server, so portals intodocument.bodymust only render on the client (for example after an effect setsmountedto true). Phase 20 covers this.
Remember this
- 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'sz-indexand stacking context (see the CSS course,/css, on positioning). Putting the element at the end ofdocument.bodyfrees it from those boxes. - 2
createPortal(children, domNode, key?)comes fromreact-dom. You return it from a component like any JSX:return createPortal(<div className="modal">…</div>, document.body).childrenis what to render,domNodeis an existing DOM element to render into (it must exist already), andkeyis optional, for lists of portals. - 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
Events bubble through the React tree. A click inside a portal triggers
onClickhandlers 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 anonClickon the card that opened it). Native listeners added withaddEventListenerfollow the DOM tree as usual, so they don't see it. Usee.stopPropagation()inside the portal if a parent must not get the event. - 5
Accessibility. Moving a modal to the end of
bodychanges reading order for screen readers, so you must manage it: give itrole="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 withshowModal()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
The target can be any element:
document.body, or a dedicated<div id="modal-root">placed after your app's root inindex.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
What is a portal and when would you use one?
A button inside a portal is clicked. Which onClick handlers run: those of its DOM ancestors or its React ancestors? Why?
What must you do to make a portal-based modal accessible?
What is the difference between a portal and a second createRoot?
Practice
Write a Toast component that portals a message into document.body and removes itself after 2 seconds.
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.bodyor another element withcreatePortal.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.