cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Render outside the parent's DOMUse createPortal to put a modal, a tooltip, or a toast into document.body, and name the CSS that traps them without it.
  • 02
    Predict events and context through a portalKnow that React events and context follow the React tree, while CSS, inert, and addEventListener follow the DOM tree.
  • 03
    Build 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.

The same quick view, without and with a portalPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Left, the React tree: App, then Shelf, then QuickView, one under the other. Right, the DOM tree: body at the top, with two children: div#root, which holds div.shelf, and div.overlay, the quick view, directly in the body.React treeAppShelfQuickViewstill under ShelfDOM treebodydiv#rootdiv.overlaydiv.shelfQuickView's DOM,in the body
With a portal, 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.

Real-life analogyA sleepover at a friend's house

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
The main idea

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's argumentsJavaScript
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 use document.body, or an empty <div id="modal-root"> written in index.html next 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?

A toast rendered into document.bodyPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

Playground: one React tree, two DOM trees
On the pageno portal
Chai
The two treesclosed

React tree

App
  Shelf

DOM tree

Try it yourself
Closed

The quick view is closed, so neither tree has it. Press Open the quick view.

The shelf from the overview, running on this page with real React and the same CSS: the track has a transform, and the shelf hides its overflow. The bordered box stands in for the page's body. The DOM tree is read from the real elements after every change.

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.

Two rules for the DOM node

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.

What traps a modal or a tooltip
An ancestor withWhat it does to a box insideIn the body
overflow: hidden, auto, or scrollCuts 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 perspectiveMakes 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 transformStarts 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.
Left, without a portal: a page window holding a small shelf box, and the QuickView card squeezed inside the shelf. Right, with a portal: the same page window, and the QuickView card large and centered, covering the page and the shelf.Without a portalpageQuickViewshelf: 200 × 100covers only the trackWith a portalpageQuickViewcovers the window
Left: the track's 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.

Order status tooltips in a scrolling listPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

A click inside the portal reaches the rowPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Left, the React tree: a click on the Close button goes up to div.modal, then up to div.row, whose onClick runs. Right, the DOM tree: the same click goes up from Close to div.modal to body. div.row, inside div#root, is off the path, so its addEventListener listener never runs.React tree: onClickdiv.rowonClick runsdiv.modalCloseyou click hereDOM tree: addEventListenerbodydiv.modalClosediv.rownever hears it
React runs handlers up the React tree, so the row's 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.

A cart drawer in document.body reads CartContextPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Which tree decides?
QuestionAnswered byIn 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.

A confirm modal with focus handlingPop out in the code editor (opens in a new tab)JSX
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>      )}    </>  );}
ResultLoading…

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, and aria-labelledby names it after the heading on line 42, through an id from useId.
  • 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.
A button, Remove chai, with an arrow labelled opens to a dialog window holding two buttons, Keep it and Remove. An arrow labelled Tab goes from Keep it down to Remove, and a curved arrow labelled Tab wraps goes from Remove back up to Keep it. A curved arrow from the dialog back to Remove chai is labelled Escape: focus goes back.Remove chaiopensdialogKeep itRemoveTabTab wrapsEscape: focus goes back
Opening the modal moves focus to Keep it. Tab moves to Remove, and Tab on the last button wraps to the first. Escape closes the modal, and focus goes back to Remove chai, the button that opened it.

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.

The same confirm dialog with the dialog elementPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

A div in a portal, or the dialog element?
JobA div through createPortal<dialog> with showModal()
Get above overflow, transforms, and z-indexYes: 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 unusableYou 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 closeYou 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 EscapeYou handle keydown.Built in: Escape fires cancel, then close.
Keep Tab insideYou write a focus trap.The page behind is inert, so Tab can't reach it.
Toasts and tooltips that don't block the pageYes.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.

A toast provider with one portal for every toastPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Portal, or keep it in place?
  • 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
Try it yourself
0 of 8 correct

Sort each piece of UI: does it need to escape its ancestors' boxes, or does it belong where it is?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 onClick runs. Only listeners added with addEventListener follow the DOM.
  • "A big enough z-index fixes any overlap." No. Inside a stacking context, z-index only competes with that context's boxes, and it can't undo overflow: hidden or a transform.
  • "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 with showModal() 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.

Exercise 1 · Warm-upFind the note's parent

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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFree the quick view

    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>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePredict the log

      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>
          </>
        );
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep the modal's clicks to itself

        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>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeEscape, and give the focus back

          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>      )}    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe banner that won't switch language

            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 />    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The portals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does createPortal change about the JSX you pass it?

                Choose an answer to see the explanation.

              2. Question 2 of 7You click Close in this page's result. What does the console show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              3. Question 3 of 7This time the row listens with addEventListener. You click Close. What does the console show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              4. Question 4 of 7A quick view has position: fixed; inset: 0; z-index: 9999, inside a carousel track that has a transform. Why does it cover only the track?

                Choose an answer to see the explanation.

              5. Question 5 of 7A component in a portal into document.body calls useContext(CartContext). App provides the context around the component that renders it. What does it read?

                Choose an answer to see the explanation.

              6. Question 6 of 7A modal closes. Where should keyboard focus go?

                Choose an answer to see the explanation.

              7. Question 7 of 7When is a native <dialog> opened with showModal() enough on its own?

                Choose an answer to see the explanation.

              Key takeaways

              • createPortal(children, domNode), from react-dom, renders the children into another DOM node, usually document.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, and addEventListener follow 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 with showModal() does most of that itself, in the top layer, with no portal. Keep React's state in step with onClose.

              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.

              CompleteFrontend Clear concepts. Working examples.