Portals
Render modals, tooltips, and toasts outside their parent's DOM with createPortal, and see how events, context, and keyboard focus work through a portal.
- 01Render outside the parent's DOMUse
createPortalto put a modal, a tooltip, or a toast intodocument.body, and name the CSS that traps them without it. - 02Predict events and context through a portalKnow that React events and context follow the React tree, while CSS,
inert, andaddEventListenerfollow the DOM tree. - 03Build a modal everyone can useLabel it, move focus in, keep Tab inside, close it with Escape, give focus back, and know when the native
<dialog>does it for you.
A quick view stuck inside a carousel
A shop's home page often shows products in a carousel: a box that hides everything outside it, with a strip inside that slides left and right. Each product card has a Quick view button that opens a small window over the page, a modal. Here is the same shelf twice. Press Quick view on the left, close it, then press the one on the right.
import { useState } from "react";import { createPortal } from "react-dom";import QuickView from "./QuickView.jsx"; function Shelf({ portal }) { const [open, setOpen] = useState(false); const close = () => setOpen(false); return ( <div className="shelf"> <div className="track"> <div className="card"> <h3>Chai</h3> <button onClick={() => setOpen(true)}>Quick view</button> </div> {open && !portal && <QuickView onClose={close} />} {open && portal && createPortal(<QuickView onClose={close} />, document.body)} </div> </div> );} export default function App() { return ( <div className="shelves"> <section> <h4>Without a portal</h4> <Shelf portal={false} /> </section> <section> <h4>With a portal</h4> <Shelf portal={true} /> </section> </div> );}Open the QuickView.jsx tab: it draws a div.overlay that the CSS fixes over the whole window (position: fixed; inset: 0), with the modal in its middle. Open styles.css: the shelf hides its overflow, and the track inside it has a transform, the CSS a carousel uses to slide.
On the left, the overlay is squeezed into the shelf and cut off at its edges. Lines 14 and 16 explain it: the button sets open, and line 16 draws QuickView right there, inside the track, where the track's CSS traps it. On the right, line 17 draws the same QuickView through createPortal, with a second argument: document.body. React puts its DOM at the end of the page's body, outside the shelf, and the overlay covers the whole result. Line 28 and line 32 use the same Shelf; only the portal prop differs.
QuickView is still a child of Shelf in the React tree. In the DOM tree, its div.overlay is a child of the body, next to div#root, and far from the shelf.The picture shows the trick. Your components form one tree, the React tree, and the page's elements form another, the DOM tree. Usually they have the same shape. A portal changes only where the DOM goes. QuickView is still Shelf's child: it got onClose from Shelf, and its Close button sets Shelf's state.
A child at a sleepover sleeps in someone else's house, in a room the family didn't choose. They are still their parents' child: if something happens, they call home, and their family's rules still apply.
- In real life: A child sleeps at a friend's house for the night
- In React: The portal's DOM nodes sit in document.body
- In real life: They're still part of their own family
- In React: In React, the content is still a child of the component that rendered it
- In real life: When something happens, they phone home
- In React: A click inside bubbles up to the React parents' handlers
- In real life: Their family's rules still apply
- In React: Context from the parents still reaches the content
A portal renders part of a component into a different DOM node, usually document.body, with createPortal(children, domNode). Only the DOM moves. In React, the content stays where you wrote it: it keeps its parent's state, context, and events.
createPortal puts the DOM somewhere else
createPortal comes from react-dom (not react-dom/client, where createRoot lives). It takes up to three arguments:
createPortal(children, domNode, key?)children: anything React can render, usually some JSX.domNode: the DOM element to put it in. It must already exist. Most apps usedocument.body, or an empty<div id="modal-root">written inindex.htmlnext to the root.key, optional: a key, for portals rendered in a list.
It returns something you render like any JSX: put it in your markup, or return it from a component. Here a profile panel shows a toast, a short message in the corner, through a portal. Press Save, then Where is the toast?
import { useState } from "react";import { createPortal } from "react-dom"; export default function App() { const [saved, setSaved] = useState(false); const [parent, setParent] = useState("not on the page yet"); function check() { const toast = document.querySelector(".toast"); setParent(toast ? toast.parentElement.tagName : "not on the page yet"); } return ( <div className="panel"> <h3>Profile</h3> <button onClick={() => setSaved(true)}>Save</button> <button onClick={check}>Where is the toast?</button> <p>The toast's parent element: {parent}</p> {saved && createPortal(<p className="toast">Profile saved</p>, document.body)} </div> );}Line 19 is written inside the panel's <div>, and renders the toast through a portal once saved is true. check on lines 8 to 11 looks the toast up in the real page and stores its parent's tag name, which line 18 shows: BODY.
Press Edit & run and change line 19 to {saved && <p className="toast">Profile saved</p>}. Save and check again: the parent is DIV, the panel. The toast looks the same, because nothing around the panel traps a fixed element. The portal only matters when something does, like the carousel's CSS. The playground runs the overview's shelf on this page and lists the real DOM after each change:
no portalclosedReact tree
App Shelf
DOM tree
The quick view is closed, so neither tree has it. Press Open the quick view.
Press Open the quick view with No portal: div.overlay appears inside div.track, and the overlay covers only the track. Close it, choose With a portal, and open it again: the overlay is now the body's last child, next to div#root, and covers the whole box. The React tree didn't change.
Pass the same node on every render: if domNode changes, React builds the content again from scratch, and any state inside it is lost. And with server rendering, as in Next.js, document doesn't exist while the server renders. Create portals for things that open after the reader does something, like the toast here, or wait until the component has mounted in the browser.
What trapped the modal: overflow, transforms, and stacking
The left shelf in the overview hit two of the three CSS traps that portals get you out of. How to read this table: each row is CSS on an ancestor, what it does to a box inside it, and what changes when the box moves to the body.
| An ancestor with | What it does to a box inside | In the body |
|---|---|---|
overflow: hidden, auto, or scroll | Cuts off whatever sticks out of the ancestor, like a tooltip under the last row of a scrolling list. | In the body, the box has no such ancestor, so nothing cuts it off. |
transform, filter, or perspective | Makes position: fixed measure from the ancestor instead of the browser window, so an overlay covers only the ancestor. | In the body, fixed measures from the window again. |
z-index with position, opacity below 1, or transform | Starts a stacking context: the box's z-index only competes with boxes inside that ancestor. | At the end of the body, the box competes with the page's top-level boxes, and comes after them. |
transform makes inset: 0 mean the track's edges, and the shelf's overflow: hidden cuts off the rest. Right: in the body, the same CSS measures from the browser window.The third row is why z-index: 9999 often doesn't help. A stacking context is a group of boxes that the browser layers as one: inside it, z-index sorts the boxes, but the whole group sits at one level among its neighbors. A modal inside a card with opacity: 0.9 can never rise above the card's next sibling, whatever its z-index.
Tooltips hit the first row all the time. Here, each order has a Status button with a tooltip, inside a list that scrolls. Move the mouse over the Status of order #1042, or Tab to it, and read the tooltip. Then tick Use a portal and try again.
import { useId, useState } from "react";import { createPortal } from "react-dom"; function StatusTip({ text, portal }) { const [box, setBox] = useState(null); const id = useId(); const show = (e) => setBox(e.currentTarget.getBoundingClientRect()); const hide = () => setBox(null); const fixed = { position: "fixed", top: box?.bottom + 4, left: box?.right - 180, right: "auto" }; const tip = box && ( <span id={id} role="tooltip" className="tip" style={portal ? fixed : null}> {text} </span> ); return ( <span className="tip-anchor"> <button aria-describedby={box ? id : undefined} onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} > Status </button> {portal && tip ? createPortal(tip, document.body) : tip} </span> );} const orders = [ { id: 1041, text: "Delivered on Monday." }, { id: 1042, text: "Out for delivery. It should arrive by 6 pm." }, { id: 1043, text: "Packed. It leaves our shop tomorrow." }, { id: 1044, text: "Waiting for your payment." },]; export default function App() { const [portal, setPortal] = useState(false); return ( <> <label> <input type="checkbox" checked={portal} onChange={(e) => setPortal(e.target.checked)} /> Use a portal </label> <div className="orders"> {orders.map((order) => ( <div className="row" key={order.id}> Order #{order.id} <StatusTip text={order.text} portal={portal} /> </div> ))} </div> </> );}Without the portal, the tooltip on line 12 sits inside the row, and the list's overflow: auto cuts it off at the list's edge. With the portal, line 28 renders it into document.body, so it needs a place on the screen. Line 7 reads the button's box from getBoundingClientRect() when the mouse or the focus arrives, and line 9 places the tooltip with position: fixed just under the button's bottom edge, lined up with its right edge. Lines 20 to 24 show it on hover and on focus, so keyboard users get it too, and aria-describedby tells screen readers to read it with the button.
A tooltip fixed to the window stays put if the list scrolls under it. Real apps use a positioning library, like Floating UI, that follows scrolling, flips the tooltip above the button near the bottom of the screen, and keeps it on screen. The useLayoutEffect lesson shows how to measure an element before the browser paints.
Events bubble through the React tree
A click on a button bubbles up: after the button's onClick, React runs the onClick of every parent. With a portal, "parent" means the parent in the React tree, not in the DOM. This order row has a Details button that opens a small modal through a portal, and the row listens to clicks twice: with React's onClick, and with the browser's own addEventListener. Press Details, then Close in the modal, and read the log.
import { useEffect, useRef, useState } from "react";import { createPortal } from "react-dom"; export default function App() { const [open, setOpen] = useState(false); const [log, setLog] = useState([]); const row = useRef(null); const add = (line) => setLog((lines) => [...lines, line]); useEffect(() => { const node = row.current; const listener = () => add("DOM listener on the row"); node.addEventListener("click", listener); return () => node.removeEventListener("click", listener); }, []); return ( <> <div ref={row} className="row" onClick={() => add("React onClick on the row")}> Order #1042 <button onClick={() => setOpen(true)}>Details</button> {open && createPortal( <div className="modal"> <p>2 × Chai: $10</p> <button onClick={() => { add("Close clicked"); setOpen(false); }} > Close </button> </div>, document.body, )} </div> <button onClick={() => setLog([])}>Clear the log</button> <ol> {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </> );}Lines 10 to 15 add a DOM listener to the row with addEventListener, in an Effect that removes it again on cleanup. Line 19 gives the row a React onClick. Lines 22 to 36 render the modal through a portal, written inside the row.
Press Details. Two lines: DOM listener on the row, then React onClick on the row. The button is inside the row in both trees, so both hear the click. (The DOM listener goes first: React handles clicks with one listener of its own, at the root, which the click reaches after the row.)
Now press Close. Two more lines: Close clicked, then React onClick on the row, and no DOM listener line. In the DOM, the Close button sits in the body, so the browser's bubbling goes from the button to the body and never passes the row. React still hears it: when a portal mounts, React adds the same listeners to the portal's container, here the body. From there, React's bubbling follows the React tree, where the modal is inside the row, so the row's onClick runs. The same goes for every React event: onKeyDown, onSubmit, onFocus, and the rest. The React's event system lesson, in the Under the hood stage, shows the code that does this.
onClick runs. The browser bubbles up the DOM tree, so a listener added to the row with addEventListener never hears the click.This is often what you want: a parent can handle a key press or a form submit from inside its modal. When it isn't, like a row that opens an order on any click, stop it in the modal. Press Edit & run and change line 24 to <div className="modal" onClick={(e) => e.stopPropagation()}>. Clear the log and try again: Close adds only Close clicked. The other fix is to render the portal outside the row, so the row isn't its parent in the React tree either.
Context reaches into a portal
Context follows the React tree too. This cart drawer slides in from the right, through a portal, and reads the cart from CartContext. Press Cart (2), then Remove next to Chai.
import { createContext, useContext, useState } from "react";import { createPortal } from "react-dom"; const CartContext = createContext(null); function CartDrawer({ onClose }) { const { items, remove } = useContext(CartContext); return createPortal( <aside className="drawer" aria-label="Your cart"> <h3>Your cart ({items.length})</h3> <ul> {items.map((item) => ( <li key={item}> {item} <button onClick={() => remove(item)}>Remove</button> </li> ))} </ul> <button onClick={onClose}>Close</button> </aside>, document.body, );} export default function App() { const [items, setItems] = useState(["Chai", "Mint tea"]); const [open, setOpen] = useState(false); const remove = (name) => setItems(items.filter((item) => item !== name)); return ( <CartContext value={{ items, remove }}> <header> <button onClick={() => setOpen(true)}>Cart ({items.length})</button> </header> {open && <CartDrawer onClose={() => setOpen(false)} />} </CartContext> );}Line 31 provides the cart, and line 35 renders CartDrawer inside the provider. Line 7 reads the context, and line 9 returns the drawer through a portal, so a component can render itself into the body. The drawer's DOM is far from the provider, but useContext looks up the React tree, so it finds App's cart. Remove Chai: the drawer says Your cart (1), and so does the header button on line 33, because both read the same state.
How to read this table: each row is a question about a portal's content, and the tree that answers it.
| Question | Answered by | In this lesson |
|---|---|---|
| Where do the DOM nodes appear? | The DOM tree: inside the node you pass to createPortal. | The quick view's overlay sits at the end of the body. |
| Which CSS reaches it: overflow, transforms, inherited styles? | The DOM tree. | The shelf's overflow and transform no longer reach the overlay. |
Which React handlers, like onClick, run? | The React tree. | A click on Close also runs the row's onClick. |
Which listeners added with addEventListener run? | The DOM tree. | The row's DOM listener never hears the Close click. |
| Which context value does it read? | The React tree. | The cart drawer reads CartContext from App. |
Does inert on an ancestor reach it? | The DOM tree. | The modal stays usable while <main inert> is not. |
A modal that keyboard users can use
A portal moves a modal's DOM. It does nothing for the people using it. A keyboard user who opens a modal needs the focus to go into it, to stay inside while it's open, a way out with Escape, and the focus back where they were when it closes. A screen reader needs to know it's a dialog, what it's called, and that the page behind doesn't count. The WAI-ARIA dialog pattern lists these rules; here is a modal that follows the main ones. The line at the top of the result shows where the focus is. Click Remove chai, press Tab a few times, then press Escape.
import { useState } from "react";import Modal from "./Modal.jsx";import FocusReadout from "./FocusReadout.jsx"; export default function App() { const [open, setOpen] = useState(false); const [packs, setPacks] = useState(2); return ( <> <FocusReadout /> <main inert={open}> <p>Chai: {packs} packs</p> <button onClick={() => setOpen(true)}>Remove chai</button> <button>Checkout</button> </main> {open && ( <Modal title="Remove chai from your cart?" onClose={() => setOpen(false)}> <p>You can add it again later.</p> <button onClick={() => setOpen(false)}>Keep it</button> <button onClick={() => { setPacks(0); setOpen(false); }} > Remove </button> </Modal> )} </> );}In App.jsx, line 14 opens the modal and lines 17 to 30 render it, with two buttons. Line 12 marks <main> as inert while the modal is open: the browser ignores clicks, focus, and screen readers inside it. React 19 accepts inert as a true or false prop; older versions needed inert="". The modal stays usable because inert follows the DOM tree, and the modal's DOM is in the body, outside <main>.
Now open the Modal.jsx tab:
- Lines 36 to 38:
role="dialog"says what it is,aria-modal="true"says the page behind doesn't count, andaria-labelledbynames it after the heading on line 42, through an id fromuseId. - Lines 11 to 15 move the focus. Line 12 remembers the element that had focus, the Remove chai button, in a ref. Line 13 focuses the first button, Keep it, the safe choice. Line 14 is the cleanup: when the modal unmounts, focus goes back to Remove chai.
- Line 18 closes the modal on Escape.
- Lines 19 to 29 are a basic focus trap. Tab on the last button moves to the first (line 28), and Shift+Tab on the first moves to the last (line 25). Everywhere else, the browser moves the focus as usual.
Why ??= on line 12? In development, Strict Mode runs the Effect, its cleanup, and the Effect again. The cleanup tries to focus Remove chai, but <main> is already inert, so the browser refuses and the focus stays on Keep it. ??= (nullish assignment) sets the ref only while it is still null, so the second run keeps Remove chai. With a plain const opener inside the Effect, the second run would remember Keep it, and closing the modal would lose the focus. This is a basic version: a complete one also skips disabled and hidden elements, and handles a modal opened from another modal. Libraries such as Radix UI and React Aria handle those cases; the Accessible React components lesson covers focus more broadly.
When the native dialog element is enough
Browsers now do most of that work themselves. The HTML <dialog> element, opened with its showModal() method, is drawn in the top layer, a layer above the whole page that no overflow, transform, or z-index can reach. So it needs no portal. Here it is inside the same kind of trap as the carousel. Click Remove chai, press Tab, then Escape.
import { useEffect, useId, useRef, useState } from "react"; function ConfirmDialog({ open, title, onClose, children }) { const ref = useRef(null); const titleId = useId(); useEffect(() => { const dialog = ref.current; if (open && !dialog.open) dialog.showModal(); if (!open && dialog.open) dialog.close(); }, [open]); return ( <dialog ref={ref} aria-labelledby={titleId} onClose={onClose}> <h3 id={titleId}>{title}</h3> {children} </dialog> );} export default function App() { const [open, setOpen] = useState(false); const [packs, setPacks] = useState(2); return ( <div className="shelf"> <p>Chai: {packs} packs</p> <button onClick={() => setOpen(true)}>Remove chai</button> <ConfirmDialog open={open} title="Remove chai from your cart?" onClose={() => setOpen(false)} > <button onClick={() => setOpen(false)}>Keep it</button> <button onClick={() => { setPacks(0); setOpen(false); }} > Remove </button> </ConfirmDialog> </div> );}There is no createPortal and no focus code. Lines 7 to 11 keep the element in step with React's open state, through a ref: showModal() when it becomes true, close() when it becomes false. Line 14 passes onClose, because Escape closes the dialog without asking React; onClose sets open back to false, so the state agrees. The browser moves the focus to Keep it, makes the rest of the page inert, closes on Escape, gives focus back to Remove chai, and draws the dark ::backdrop behind it.
Don't open a modal with React's open prop on <dialog>: the open attribute shows the dialog as non-modal, in the page, with none of the above. How to read this table: each row is a job a modal needs done, and who does it in each approach.
| Job | A div through createPortal | <dialog> with showModal() |
|---|---|---|
Get above overflow, transforms, and z-index | Yes: its DOM moves to the end of the body. | Yes: the browser draws it in the top layer, above everything. |
| Make the page behind it unusable | You add inert to the rest of the page. | The browser makes the rest of the page inert. |
| Move focus in, and give it back on close | You write it, with a ref and an Effect. | The browser moves focus in, and back to where it was when the dialog closes. |
| Close with Escape | You handle keydown. | Built in: Escape fires cancel, then close. |
| Keep Tab inside | You write a focus trap. | The page behind is inert, so Tab can't reach it. |
| Toasts and tooltips that don't block the page | Yes. | Not with showModal(): it blocks the page. Use show(), or the popover attribute. |
For a confirm box, a form, or a picture viewer, <dialog> with showModal() is usually enough. Portals stay useful for what shouldn't block the page: toasts, tooltips, and menus (the popover attribute, which React 19 supports, is another top-layer option for those), and for libraries that need their content in one place in the DOM.
Toasts from anywhere in the shop
Any part of a shop might want to say "Added to your cart". A toast system puts the two lessons together: a provider and a hook, so any component can show a toast, and a portal, so the toasts appear in one corner of the page wherever the button sits. The buttons here are inside a carousel again. Press Add Chai and Add Mint tea.
import { ToastProvider, useToast } from "./Toasts.jsx"; function AddButton({ name }) { const toast = useToast(); return <button onClick={() => toast(`${name} added to your cart`)}>Add {name}</button>;} export default function App() { return ( <ToastProvider> <div className="shelf"> <AddButton name="Chai" /> <AddButton name="Mint tea" /> </div> </ToastProvider> );}In App.jsx, line 4 gets the toast function from useToast, and line 5 calls it on click. In Toasts.jsx, show on lines 10 to 16 adds a toast to the list with an id from line 5, and starts a timer that removes it 3 seconds later. The timer starts in the event handler, not in an Effect, because the click is what causes it.
Lines 21 to 28 render one div.toasts through a portal, always, even with no toasts in it. It has role="status", which makes it a live region: screen readers read out text that appears inside it. A live region must already be on the page when the text arrives, so it can't appear together with its first toast. Line 19 provides show to the whole shop, and the portal sits inside that provider, in the React tree. With server rendering, this always-there portal is the case the "Two rules for the DOM node" box warns about: render it only once the provider has mounted in the browser.
- A modal that must cover the whole page
- A tooltip on a button inside a scrolling table
- A toast in the corner, whichever page is open
- A dropdown menu in a card with
overflow: hidden - An error message under a form field
- A Sale badge on a product card
- An accordion section that opens under its heading
- A sidebar that is part of the page layout
Sort each piece of UI: does it need to escape its ancestors' boxes, or does it belong where it is?
Common mix-ups
- "A portal makes a separate React app." No. The content stays in the same React tree. It keeps its parent's state, context, and event handlers.
- "Clicks in a portal bubble through the DOM, so the parent's onClick won't run." No. React events follow the React tree, so the parent's
onClickruns. Only listeners added withaddEventListenerfollow the DOM. - "A big enough z-index fixes any overlap." No. Inside a stacking context,
z-indexonly competes with that context's boxes, and it can't undooverflow: hiddenor atransform. - "Context doesn't reach into a portal." No. Context looks up the React tree, so the cart drawer in the body reads
App's cart. - "Putting a modal in a portal makes it accessible." No. A portal only moves the DOM. The modal still needs a role and a name, focus moved in and back, Escape, and an inert page behind it.
- "Every modal needs a portal." No. A
<dialog>opened withshowModal()is drawn in the top layer, above every trap, wherever it sits in the DOM.
Practice exercises
Each exercise shows its code's result under it, or when you press Edit & run. A portal's content shows up in the result like everything else. Try a guess first, then use the hints.
Which element is #note's parent in the DOM? Answer first, then press Edit & run to check.
import { createPortal } from "react-dom";
export default function App() {
return (
<div id="panel">
<p>Your profile</p>
{createPortal(<p id="note">Saved</p>, document.body)}
</div>
);
}import { createPortal } from "react-dom"; export default function App() { return ( <div id="panel"> <p>Your profile</p> {createPortal(<p id="note">Saved</p>, document.body)} </div> );}createPortal puts <p id="note"> inside the node it's given, document.body. In the DOM, its parent is the body, not #panel. In React, it is still part of App.
Press Quick view: the overlay is stuck in the shelf. Render QuickView through a portal into document.body. After you click Quick view, which element is the overlay's parent?
import { useState } from "react";import QuickView from "./QuickView.jsx"; export default function App() { const [open, setOpen] = useState(false); return ( <div className="shelf"> <div className="track"> <div className="card"> <h3>Chai</h3> <button onClick={() => setOpen(true)}>Quick view</button> </div> {open && <QuickView onClose={() => setOpen(false)} />} </div> </div> );}import { useState } from "react";import { createPortal } from "react-dom";import QuickView from "./QuickView.jsx"; export default function App() { const [open, setOpen] = useState(false); return ( <div className="shelf"> <div className="track"> <div className="card"> <h3>Chai</h3> <button onClick={() => setOpen(true)}>Quick view</button> </div> {open && createPortal(<QuickView onClose={() => setOpen(false)} />, document.body)} </div> </div> );}With the portal, the overlay's DOM goes into document.body, outside the track and the shelf. Their transform and overflow no longer reach it, and it covers the whole result. QuickView and its onClose didn't change.
You click Details, then Close. What is the last line of the log?
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export default function App() {
const [open, setOpen] = useState(false);
const [log, setLog] = useState([]);
const row = useRef(null);
const add = (line) => setLog((lines) => [...lines, line]);
useEffect(() => {
const node = row.current;
const listener = () => add("DOM listener on the row");
node.addEventListener("click", listener);
return () => node.removeEventListener("click", listener);
}, []);
return (
<>
<div ref={row} className="row" onClick={() => add("React onClick on the row")}>
Order #1042
<button onClick={() => setOpen(true)}>Details</button>
{open &&
createPortal(
<div className="modal">
<p>2 × Chai: $10</p>
<button
onClick={() => {
add("Close clicked");
setOpen(false);
}}
>
Close
</button>
</div>,
document.body,
)}
</div>
<button onClick={() => setLog([])}>Clear the log</button>
<ol>
{log.map((line, index) => (
<li key={index}>{line}</li>
))}
</ol>
</>
);
}import { useEffect, useRef, useState } from "react";import { createPortal } from "react-dom"; export default function App() { const [open, setOpen] = useState(false); const [log, setLog] = useState([]); const row = useRef(null); const add = (line) => setLog((lines) => [...lines, line]); useEffect(() => { const node = row.current; const listener = () => add("DOM listener on the row"); node.addEventListener("click", listener); return () => node.removeEventListener("click", listener); }, []); return ( <> <div ref={row} className="row" onClick={() => add("React onClick on the row")}> Order #1042 <button onClick={() => setOpen(true)}>Details</button> {open && createPortal( <div className="modal"> <p>2 × Chai: $10</p> <button onClick={() => { add("Close clicked"); setOpen(false); }} > Close </button> </div>, document.body, )} </div> <button onClick={() => setLog([])}>Clear the log</button> <ol> {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </> );}Details adds DOM listener on the row and React onClick on the row. Close adds Close clicked, then bubbles up the React tree to the row's onClick, which adds React onClick on the row. The DOM listener doesn't hear it: in the DOM, Close is in the body.
Make clicks inside the modal stop before they reach the row. Clear the log, then press Details and Close. How many lines does the log show?
import { useEffect, useRef, useState } from "react";import { createPortal } from "react-dom"; export default function App() { const [open, setOpen] = useState(false); const [log, setLog] = useState([]); const row = useRef(null); const add = (line) => setLog((lines) => [...lines, line]); useEffect(() => { const node = row.current; const listener = () => add("DOM listener on the row"); node.addEventListener("click", listener); return () => node.removeEventListener("click", listener); }, []); return ( <> <div ref={row} className="row" onClick={() => add("React onClick on the row")}> Order #1042 <button onClick={() => setOpen(true)}>Details</button> {open && createPortal( <div className="modal"> <p>2 × Chai: $10</p> <button onClick={() => { add("Close clicked"); setOpen(false); }} > Close </button> </div>, document.body, )} </div> <button onClick={() => setLog([])}>Clear the log</button> <ol> {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </> );}import { useEffect, useRef, useState } from "react";import { createPortal } from "react-dom"; export default function App() { const [open, setOpen] = useState(false); const [log, setLog] = useState([]); const row = useRef(null); const add = (line) => setLog((lines) => [...lines, line]); useEffect(() => { const node = row.current; const listener = () => add("DOM listener on the row"); node.addEventListener("click", listener); return () => node.removeEventListener("click", listener); }, []); return ( <> <div ref={row} className="row" onClick={() => add("React onClick on the row")}> Order #1042 <button onClick={() => setOpen(true)}>Details</button> {open && createPortal( <div className="modal" onClick={(e) => e.stopPropagation()}> <p>2 × Chai: $10</p> <button onClick={() => { add("Close clicked"); setOpen(false); }} > Close </button> </div>, document.body, )} </div> <button onClick={() => setLog([])}>Clear the log</button> <ol> {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </> );}Close's handler runs first and adds Close clicked. Then the click reaches the modal's <div>, which stops it, so the row's onClick never runs. Details adds 2 lines and Close adds 1: 3 in all.
This Modal.jsx has no Escape and doesn't give the focus back. Fix both. Then open the dialog with Remove chai and press Escape. What does the readout say focus is on?
import { useState } from "react";import Modal from "./Modal.jsx";import FocusReadout from "./FocusReadout.jsx"; export default function App() { const [open, setOpen] = useState(false); const [packs, setPacks] = useState(2); return ( <> <FocusReadout /> <main inert={open}> <p>Chai: {packs} packs</p> <button onClick={() => setOpen(true)}>Remove chai</button> <button>Checkout</button> </main> {open && ( <Modal title="Remove chai from your cart?" onClose={() => setOpen(false)}> <p>You can add it again later.</p> <button onClick={() => setOpen(false)}>Keep it</button> <button onClick={() => { setPacks(0); setOpen(false); }} > Remove </button> </Modal> )} </> );}import { useState } from "react";import Modal from "./Modal.jsx";import FocusReadout from "./FocusReadout.jsx"; export default function App() { const [open, setOpen] = useState(false); const [packs, setPacks] = useState(2); return ( <> <FocusReadout /> <main inert={open}> <p>Chai: {packs} packs</p> <button onClick={() => setOpen(true)}>Remove chai</button> <button>Checkout</button> </main> {open && ( <Modal title="Remove chai from your cart?" onClose={() => setOpen(false)}> <p>You can add it again later.</p> <button onClick={() => setOpen(false)}>Keep it</button> <button onClick={() => { setPacks(0); setOpen(false); }} > Remove </button> </Modal> )} </> );}Escape now calls onClose, and the modal unmounts. Its Effect's cleanup focuses the element that had focus when it opened, the Remove chai button, so the readout says Focus is on: Remove chai. Solution's Modal.jsx is the one from the focus section.
Switch language changes the text above the button, but the banner in the corner stays in English. Fix it. After one click on Switch language, what does the banner say?
import { createContext, useContext, useState } from "react";import { createPortal } from "react-dom"; const LanguageContext = createContext("en");const greetings = { en: "Welcome back, Asha!", hi: "Namaste, Asha!" }; function WelcomeBanner() { const language = useContext(LanguageContext); return createPortal(<p className="banner">{greetings[language]}</p>, document.body);} export default function App() { const [language, setLanguage] = useState("en"); return ( <> <LanguageContext value={language}> <p>Language: {language}</p> <button onClick={() => setLanguage(language === "en" ? "hi" : "en")}> Switch language </button> </LanguageContext> <WelcomeBanner /> </> );}import { createContext, useContext, useState } from "react";import { createPortal } from "react-dom"; const LanguageContext = createContext("en");const greetings = { en: "Welcome back, Asha!", hi: "Namaste, Asha!" }; function WelcomeBanner() { const language = useContext(LanguageContext); return createPortal(<p className="banner">{greetings[language]}</p>, document.body);} export default function App() { const [language, setLanguage] = useState("en"); return ( <> <LanguageContext value={language}> <p>Language: {language}</p> <button onClick={() => setLanguage(language === "en" ? "hi" : "en")}> Switch language </button> <WelcomeBanner /> </LanguageContext> </> );}WelcomeBanner was rendered outside the provider, so useContext got the default, "en", whatever the state. Inside the provider, it reads the state, and after one click it says Namaste, Asha!. The portal had nothing to do with the bug: where the DOM goes never changes which provider is found.
Check your understanding
For each question, ask which tree answers it: the React tree for events and context, the DOM tree for CSS and the browser's own listeners. Press Edit & run on a question's code to check your answer.
Question 1 of 7What does
createPortalchange about the JSX you pass it?Choose an answer to see the explanation.
Question 2 of 7You click Close in this page's result. What does the console show?
Read the code, then predictimport { createPortal } from "react-dom"; export default function App() { return ( <div onClick={() => console.log("row")}> {createPortal( <button onClick={() => console.log("button")}>Close</button>, document.body, )} </div> ); }Choose an answer to see the explanation.
Question 3 of 7This time the row listens with
addEventListener. You click Close. What does the console show?Read the code, then predictimport { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; export default function App() { const row = useRef(null); useEffect(() => { const node = row.current; const listener = () => console.log("row listener"); node.addEventListener("click", listener); return () => node.removeEventListener("click", listener); }, []); return ( <div ref={row}> {createPortal(<button>Close</button>, document.body)} </div> ); }Choose an answer to see the explanation.
Question 4 of 7A quick view has
position: fixed; inset: 0; z-index: 9999, inside a carousel track that has atransform. Why does it cover only the track?Choose an answer to see the explanation.
Question 5 of 7A component in a portal into
document.bodycallsuseContext(CartContext).Appprovides the context around the component that renders it. What does it read?Choose an answer to see the explanation.
Question 6 of 7A modal closes. Where should keyboard focus go?
Choose an answer to see the explanation.
Question 7 of 7When is a native
<dialog>opened withshowModal()enough on its own?Choose an answer to see the explanation.
Key takeaways
createPortal(children, domNode), fromreact-dom, renders the children into another DOM node, usuallydocument.body.- Portals get modals, tooltips, menus, and toasts out of ancestors that clip them (
overflow), measure fixed positions from themselves (transform), or hold them in a stacking context. - Only the DOM moves. React events bubble and context flows through the React tree; CSS,
inert, andaddEventListenerfollow the DOM tree. - A click inside a portal runs its React parents' handlers. Stop it with
e.stopPropagation()in the portal, or render the portal elsewhere, when that isn't what you want. - A modal needs
role="dialog",aria-modal, a name, focus moved in and kept inside, Escape, focus given back, and an inert page behind it. - A
<dialog>opened withshowModal()does most of that itself, in the top layer, with no portal. Keep React's state in step withonClose.
Remember the one-liner.
A portal moves the DOM, not the component: events and context still follow the React tree.
Coming next: Accessible React components. It goes further than the modal: labels and useId, managing focus across a page, keyboard support, and announcing changes.