Skip to content
Chapter 22Lesson 5

Portals and createPortal

Render modals, toasts, and popovers outside their CSS traps with React's createPortal.

For four lessons your components have rendered exactly where you put them. Drop a <Button> inside a <Card> and its DOM lands inside the card’s DOM. That correspondence, between where a component sits in your code and where its markup sits in the page, is what makes JSX feel like writing HTML. For one kind of UI, it works against you.

Take the hover-lift <Card> from earlier in this chapter, the one with a transform that nudges it up two pixels on hover. Inside it you put a “Delete account?” dialog with fixed inset-0, the way every modal covers the screen. It doesn’t cover the screen. The dialog is trapped inside the card: clipped by the card’s edges, sized against the card instead of the viewport, and sitting two pixels too high because the card moved. Your code is correct; the dialog is just in the wrong place in the DOM.

That is the problem this lesson solves, and the fix is a portal: a component can sit in the right place in your code and the wrong place in the rendered page. By the end you’ll open shadcn’s Dialog, Sheet, Popover, and Sonner and know exactly what every line does, because each is built on a portal.

A handful of CSS properties on an ancestor, transform, filter, perspective, will-change, and contain, turn that ancestor into a containing block for any position: fixed descendant. Once that happens, fixed resolves against the ancestor, not the viewport. A hover-lift card with transform: translateY(-2px) is exactly such an ancestor, so the dialog’s fixed inset-0 pins it to the card’s box instead of the screen.

Here is the bug as real CSS, the example we’ll carry through the lesson:

viewport fixed inset-0
wanted all of this
Delete account? position: fixed
transform: translateY(-2px) · overflow: hidden
The card's transform makes it the containing block, so the dialog's position: fixed resolves against the card — not the viewport. It never reaches the dashed viewport edges, falls short of the card's own bounds, and is sliced off at the right by the card's overflow: hidden. Every box here is real CSS: open devtools and inspect it.

That transform is one of three traps that share a single fix. The containing-block trap you just saw: a transform, filter, or perspective ancestor takes fixed away from the viewport. The overflow clip: an ancestor with overflow: hidden or overflow: auto, such as a scrollable table or a card with rounded corners, slices off anything that escapes its box, so a dropdown rendered inside it loses its bottom. And stacking-context burial: a stacking context traps its children’s z-index, so your overlay loses to a sibling’s content even at z-index: 9999.

You can patch each one alone, but each patch falls short. Raising the z-index or adding isolation: isolate fixes burial but does nothing for the containing-block trap, since it controls stacking, not where fixed measures from. Restructuring the DOM works but breaks apart the component boundaries you spent this chapter drawing. One move escapes all three at once: render the overlay’s DOM outside the trapping subtree.

The component still belongs in the tree where you wrote it, because that’s where its props, state, and event handlers live. Only its DOM needs to move. That split, between where the component lives and where its markup lands, is what a portal gives you.

The exercise below hands you the trapped setup; free the dialog with CSS alone.

The dialog here should cover the whole screen the way every modal does, but the card's transform traps it: clipped at the card's edge, sized to the card instead of the viewport. Try to free it with CSS alone: raise the z-index, switch positioning, reach for anything. You'll find you can't get there from inside the card. The fix in the next section isn't CSS.

Target
Your output LIVE

The reference solution reaches the target by doing something CSS alone can’t do from inside the card: it renders the dialog as a sibling of the card rather than a child, so nothing transformed sits between the dialog and the root. That structural move, the DOM relocated while the React tree stays untouched, is the portal, and it’s what comes next.

createPortal: rendering into a different part of the DOM

Section titled “createPortal: rendering into a different part of the DOM”

createPortal lives in react-dom, not react:

import { createPortal } from 'react-dom';

The signature is createPortal(children, domNode, key?). You call it inside a component’s render and return what it gives back, like JSX. The difference is the second argument: instead of placing children’s DOM where the component sits, you name the DOM node to place them under.

createPortal(<DialogContent />, document.body);

This renders <DialogContent /> as a child of <body>, while the calling component stays where it is in the React tree. Apply it to the trapped dialog and the trap dissolves: the dialog’s only DOM ancestor is now <body>, with no transformed card in the chain. position: fixed measures against the viewport again, the clipping overflow: hidden is off the path, and there’s no buried stacking context to lose to. The dialog covers the screen, not because you changed its CSS, but because you changed where its DOM lives.

The second argument is the portal target, the node the content attaches to. document.body is the daily reach for modals and toasts, and the one you’ll use 95% of the time.

The optional third argument is a key. You need it only when you render a list of portals into the same container and React needs a stable identity for each, almost never the case for a single modal. Keys are the next chapter’s topic; for now, just know the argument exists.

Once the target is anything other than document.body, watch for one failure: createPortal throws if the target node is null. Call document.getElementById('portal-root') before that element has rendered and you get null, and the render crashes. Default to document.body, which always exists, or make sure the node is mounted before you portal into it.

Here’s the smallest Modal that puts this together, in a file modal.tsx exporting Modal.

'use client';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type ModalProps = {
children: ReactNode;
className?: string;
onClose: () => void;
};
export const Modal = ({ children, className, onClose }: ModalProps) =>
createPortal(
<div onClick={onClose} className="fixed inset-0 grid place-items-center bg-black/50">
<div className={cn('rounded-xl bg-background p-6 shadow-xl', className)}>
{children}
</div>
</div>,
document.body,
);

A portal touches document, which only exists in the browser, so the file is a Client Component.

'use client';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type ModalProps = {
children: ReactNode;
className?: string;
onClose: () => void;
};
export const Modal = ({ children, className, onClose }: ModalProps) =>
createPortal(
<div onClick={onClose} className="fixed inset-0 grid place-items-center bg-black/50">
<div className={cn('rounded-xl bg-background p-6 shadow-xl', className)}>
{children}
</div>
</div>,
document.body,
);

A plain component: children to show, an onClose callback, and an optional className for the panel. Nothing here hints that the DOM is about to relocate.

'use client';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type ModalProps = {
children: ReactNode;
className?: string;
onClose: () => void;
};
export const Modal = ({ children, className, onClose }: ModalProps) =>
createPortal(
<div onClick={onClose} className="fixed inset-0 grid place-items-center bg-black/50">
<div className={cn('rounded-xl bg-background p-6 shadow-xl', className)}>
{children}
</div>
</div>,
document.body,
);

A full-screen backdrop (fixed inset-0, semi-transparent black) centering a panel. This is the JSX that was getting trapped; clicking the backdrop calls onClose.

'use client';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type ModalProps = {
children: ReactNode;
className?: string;
onClose: () => void;
};
export const Modal = ({ children, className, onClose }: ModalProps) =>
createPortal(
<div onClick={onClose} className="fixed inset-0 grid place-items-center bg-black/50">
<div className={cn('rounded-xl bg-background p-6 shadow-xl', className)}>
{children}
</div>
</div>,
document.body,
);

The line that does the escaping. createPortal renders that JSX under document.body, far from any transformed or overflow-clipped ancestor.

'use client';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type ModalProps = {
children: ReactNode;
className?: string;
onClose: () => void;
};
export const Modal = ({ children, className, onClose }: ModalProps) =>
createPortal(
<div onClick={onClose} className="fixed inset-0 grid place-items-center bg-black/50">
<div className={cn('rounded-xl bg-background p-6 shadow-xl', className)}>
{children}
</div>
</div>,
document.body,
);

createPortal comes from react-dom, never from react.

1 / 1

That Modal is intentionally missing focus handling, the Esc key, and scroll lock; those are the subject of two sections from now.

That leaves the 'use client' at the top. createPortal(<X />, document.body) reads document, which doesn’t exist on the server. In a Next.js app every component is a Server Component until you say otherwise, so rendering this one on the server would crash on the missing document. The 'use client' directive marks the boundary where the component runs in the browser instead, a boundary you’ll learn properly in a later chapter. For now, treat it as a fact: any file that touches document, which means every portal, needs 'use client' at the top. shadcn’s portal-based components all carry it, part of why you can drop them in without thinking.

Portals move the DOM node, not the React tree

Section titled “Portals move the DOM node, not the React tree”

The mistake almost everyone makes: the DOM node moves; the React tree does not. A portaled component is still, as far as React is concerned, a child of the component that rendered it. Its markup lands under <body>, but in the tree React reasons about, it never moved.

That one fact has three consequences, each something beginners assume a portal breaks.

Context flows through it. A theme or auth provider above the portal in the React tree still reaches the portaled content, even though that content’s DOM lives far away under <body>. Context follows the tree, and the tree is unchanged. (Context arrives a couple of chapters from now; here, just note that a portal does not cut a component off from its providers.)

State lives where it’s declared. A portal is a destination for DOM, not a boundary on the React side. State declared in the parent drives the portaled UI exactly as it would if the markup had rendered in place.

Events bubble through the React tree, not the DOM tree. Picture a <div onClick={…}> that renders a portaled <Tooltip> among its children. In the DOM that tooltip is now a sibling of the div, sitting under <body>, so a real DOM click on it would never bubble to the div: they aren’t ancestor and descendant anymore. But React replays the event along the tree it knows, where the tooltip is still the div’s child, so the div’s onClick fires. Event propagation stays tied to the structure you wrote, no matter where the DOM landed.

This tree-stays-intact property is the whole point of portals. It is worth hearing from the architecture angle, not the CSS one.

The diagram below shows the same tooltip in both trees at once. Watch where the highlighted node sits as you flip between the tabs.

<body>
<div id="root">
the entire app
<header>
<button>
the trigger
<Tooltip>
portaled here — sibling of the app
Where the markup actually lives. The portaled <Tooltip> is a sibling of the whole app, hanging straight off <body> — nowhere near the trigger that owns it.

This bubbling rule has one consequence worth naming: the “click outside to close” pattern. You put an onClick on a wrapper that closes a menu when anything outside it is clicked. If the menu is portaled and you rely on React’s bubbling, clicks inside the menu still bubble up the React tree to that wrapper, closing the menu the instant the user touches it, the opposite of what you want. Two fixes: call e.stopPropagation() inside the portal so the click doesn’t climb the React tree, or attach the outside-click listener to document, a real DOM listener that sees the portal as the <body> sibling it actually is. Production menu libraries handle this for you; the point is to recognize why the bug happens when you see it.

Predict what this prints. A parent <div> logs on click; it renders a portaled button that also logs on click. The user clicks the button.

Predict what this program prints, then press Check.

The button’s DOM lands under <body> — a sibling of the parent <div>, not a descendant. A native DOM click would never travel between two siblings. The user clicks the button.

const Parent = () => (
<div onClick={() => console.log('parent clicked')}>
{createPortal(
<button onClick={() => console.log('button clicked')}>
Open
</button>,
document.body,
)}
</div>
);

You took a piece of UI out of the normal document flow and dropped it on top of the page. In normal flow the platform hands you a lot for free: Tab moves focus in a sensible order, the page scrolls, and Esc and the back button do what users expect. Portal an overlay on top of everything and you step outside that safety net. An overlay that traps a keyboard or screen-reader user isn’t rough around the edges, it’s unusable for them. So a portal doesn’t just move DOM, it hands you a contract.

That contract has a name: the WAI-ARIA APG modal-dialog pattern. Here is the checklist it asks for, and what a real user loses when each line is missing.

  1. role="dialog" and aria-modal="true". Without these, a screen reader announces your overlay as a generic chunk of page and never tells the user the rest is now inert.

  2. aria-labelledby pointing at the title, aria-describedby at the body. These name and describe the dialog, so a screen-reader user hears “Delete account, dialog” instead of silence on open.

  3. Move focus into the dialog when it opens. If focus stays on the trigger behind the overlay, a keyboard user is typing into a page they can’t see.

  4. Trap focus inside while it’s open, so Tab and Shift+Tab cycle within the dialog. Skip this and Tab walks the user out into the frozen content underneath, with no way back.

  5. Restore focus to the trigger on close. Otherwise focus snaps to the top of the document and the user has to find their place again.

  6. Esc closes it. This is the universal “get me out,” and keyboard users reach for it first.

  7. A click on the backdrop closes it, except for destructive or data-loss dialogs, where you want a stray click to do nothing. A “Delete account?” dialog is exactly the case to leave out.

  8. Lock body scroll while it’s open. If the page behind scrolls under the modal, the user loses their place and the overlay feels unanchored.

That is a real list with real engineering behind it, and the focus trap alone is fiddly to get right. So you do not hand-build this. shadcn’s Dialog, with Radix underneath, ships every one of these guarantees correctly today. Your job is to know the checklist, so when you audit a modal in a real codebase you can tell at a glance whether it’s accessible or quietly broken. Building the focus trap and scroll lock from scratch comes later, in the project chapter.

The modal below ships its markup but skips two items from the contract. What breaks for the user?

This modal is portaled to <body> and, on open, runs panelRef.current.focus() to move focus onto the panel. The backdrop and panel carry role="dialog", aria-modal, and an aria-labelledby wired to the title. There is no keydown listener and no logic that confines Tab to the panel.

createPortal(
<div className="fixed inset-0 grid place-items-center bg-black/50">
<div ref={panelRef} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby="t">
<h2 id="t">Rename project</h2>
<input className="..." />
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body,
)

A keyboard-only user opens it. Which problems do they actually hit? Select all that apply.

After a couple of Tab presses they land on a link in the page underneath — which is still sitting there, behind the backdrop, fully reachable.
The reflex of tapping the key in the top-left to bail out does nothing; the only exit is to Tab around until they reach the Close button.
On open, the keystrokes they type go to whatever was focused before, not to the panel.
A screen reader reaches the panel and can’t tell the user what this overlay is called.

Almost every portal is one of three shapes, and you’ll import all three from a library rather than write them. Knowing which library owns which shape, and why each needs a portal, is what lets you read that code.

Modals and dialogs

An overlay that covers the page no matter what transform, overflow, or z-index traps sit between the trigger and the root. The production answer is shadcn’s Dialog, AlertDialog, and Sheet (the side-anchored variant). You compose them; the portal and the full accessibility contract are already inside.

Toasts

A container portaled to <body> once, with each toast fixed to a corner of the viewport. The toasts stack with gap, auto-dismiss, and animate in and out via the data-state and tw-animate-css pattern from the last chapter. The shadcn default is sonner: add a single <Toaster /> to the root layout and call toast('Saved') from anywhere. Because the container lives on <body>, toasts survive route changes, exactly what you want for a “your export is ready” message that lands after the user has navigated on.

Popovers, dropdowns, tooltips

These must escape a parent’s overflow and follow the trigger as the page scrolls. position: absolute inside the trigger gets clipped by overflow: hidden; fixed plus a portal escapes the clip but then needs JavaScript to track the trigger’s position. There are two production answers: @floating-ui/react, which does the positioning math in JS, and CSS anchor positioning, which increasingly does it with no JS at all. The shadcn surface for these comes in a later chapter.

Portals plus hand-rolled focus and scroll management were always a workaround for two gaps in the platform: no way to render above every stacking context, and no way to anchor one element to another without JavaScript. In 2026 the browser is closing both, and reading new code well means knowing which of these problems are now its job.

Native <dialog> and showModal(). The <dialog> element has been Baseline since 2022. Call dialog.showModal() and the browser renders it in the top layer , above every stacking context, so there’s no portal, no z-index to manage, and no containing-block trap. You also get Esc-to-close and a focus trap for free, and the ::backdrop pseudo-element styles the scrim with plain CSS, natively doing most of what the portal-plus-accessibility dance once did by hand.

page root
z-index: 2 Sibling panel its context (2) beats the card's (1), so it paints over the overlay
card · position: relative · z-index: 1
z-index: 9999 Overlay buried — 9999 can't
escape its context
A high z-index can't escape its stacking context. The overlay's z-index: 9999 is sealed inside the card (its own context at z-index: 1), so a sibling at z-index: 2 paints right over it — the 9999 never gets to compete. Every box is real CSS: open devtools and inspect the stacking.

So why does shadcn still ship a portal-based Dialog instead of wrapping native <dialog>? Because the two solve slightly different problems:

  • Reach for native <dialog> + showModal() when platform behavior and standard styling are enough. It’s the lean option: no library, no portal, focus trap and Esc included.
  • Reach for shadcn’s Dialog (portal plus Radix-managed focus) when you need animated entrance and exit choreography, custom backdrop interaction, or open/closed state wired into your React state. Radix keeps the DOM mounted through the exit transition via the data-state pattern, so the modal can animate out before it unmounts; raw <dialog> won’t orchestrate that for you.

In this project you’ll reach for shadcn’s Dialog daily, with native <dialog> as the leaner choice worth knowing for simple cases.

CSS anchor positioning is the platform’s answer to popover tracking, and it reached Baseline in 2026. You put anchor-name on the trigger, position-anchor and position-area on the popover, and @position-try to flip it off a colliding viewport edge; the popover then tracks its trigger with no JavaScript. Pair it with the popover attribute (and <button popovertarget> to wire up a trigger) and the popover renders in the top layer, never gets clipped by an ancestor’s overflow, and needs no portal. The positioning math is becoming the browser’s job.

Portals stay essential for what the platform doesn’t cover yet, animated overlays and framework-controlled state, and they’re still what shadcn ships, so you’ll read them daily, now as an engineer who knows which problems the browser has quietly taken off your plate.

The reference set, in the order you’ll reach for it: the React API, the platform features that are replacing parts of it, the contract you audit against, and the two shadcn surfaces you’ll actually import.