Skip to content
Chapter 22Lesson 5

Portals and the layout escape

Use React's createPortal to render modals, toasts, and popovers outside the CSS traps that would otherwise cage them on the page.

For four lessons you have written components that render exactly where you put them. You 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 specific kind of UI, that same correspondence works against you.

Take the hover-lift <Card> from earlier in this chapter, the one with the subtle transform that nudges it up two pixels on hover. Inside it you put a “Delete account?” confirmation dialog, and you give that dialog fixed inset-0 so it covers the whole screen, the way every modal does. 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. Everything about your code is correct. The dialog is simply in the wrong place in the DOM.

That is the entire problem this lesson solves. A component can be in the right place in your code and the wrong place in the rendered page, and the fix is a portal. We start with the trap, so the fix has something to act on. Then comes createPortal itself, followed by the part that catches everyone off guard: the portal moves the DOM but leaves the React tree completely intact. From there we cover the responsibilities the browser quietly stops handling once an overlay covers the page, and the platform features that are starting to make parts of this work obsolete. By the end you’ll open shadcn’s Dialog, Sheet, Popover, and Sonner and know exactly what every line is doing, because every one of them is built on what’s in this lesson.

The layout trap that portals exist to solve

Section titled “The layout trap that portals exist to solve”

We lead with the bug rather than the API, because the bug is what makes the API worth learning. createPortal is a simple function to call, and the part worth remembering is when you reach for it.

Recall from when you learned about stacking contexts that a handful of CSS properties on an ancestor, namely transform, filter, perspective, will-change, and contain, turn that ancestor into a containing block for any position: fixed descendant. Once that happens, fixed stops resolving against the viewport and resolves against that ancestor instead. 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 rather than the screen.

Here is the bug as real CSS, the example we’ll carry through the whole 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 all share one fix. The first is the containing-block trap you just saw: a transform (or filter, perspective, and so on) ancestor takes fixed away from the viewport. The second is the overflow clip: an ancestor with overflow: hidden or overflow: auto, such as a scrollable table or a card with rounded corners, clips anything that tries to escape its box, so a dropdown rendered inside it gets its bottom sliced off. The third is 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 of these one at a time. Raise the z-index, add isolation: isolate to force a clean stacking context for the burial case, or restructure the DOM so the troublesome ancestor isn’t in the way. But isolation does nothing for the containing-block trap, because it fixes stacking, not where fixed measures from. And restructuring the DOM means breaking apart the component boundaries you spent this chapter drawing. One fix escapes all three at once without any of that: render the overlay’s DOM outside the trapping subtree entirely.

Keep this idea in mind, because the rest of the lesson builds on it. The component belongs in the tree where you wrote it, because that’s where its props, its state, and its event handlers live. Only its DOM needs to move. That split, between where the component lives and where its markup lands, is precisely what a portal gives you.

Try the wall for yourself first. The exercise below hands you the trapped setup, and your job is to 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. That import line is a common stumbling point, so it’s worth fixing in your memory now:

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, the same way you return JSX. The difference is the second argument: instead of letting React place children’s DOM where the component sits, you name the DOM node you want them placed under.

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

This renders <DialogContent />’s markup as a child of <body>, while the component calling createPortal stays exactly 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. So position: fixed measures against the viewport again, the overflow: hidden that was clipping it is gone from the path, and there’s no buried stacking context to lose to. The dialog covers the screen, and not because you changed any of its own 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 it’s 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, which is almost never the case for a single modal. Keys are the next chapter’s topic; for now it’s enough to know the argument exists.

One failure is worth watching for once the target is anything other than document.body: createPortal throws if the target node is null. If you reach for document.getElementById('portal-root') before that element has rendered, 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 named modal.tsx that exports Modal. The steps below walk through it.

'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. We’ll come back to why this directive matters for portals at the end of the section.

'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, and 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 takes that JSX and renders its DOM under document.body, far from any transformed or overflow-clipped ancestor. The same JSX as before lands in a different DOM home.

'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 import to get right: createPortal comes from react-dom, never from react.

1 / 1

That Modal is intentionally missing things: no focus handling, no Esc key, no scroll lock. That’s not an oversight; it’s the subject of two sections from now. The file follows the usual convention, a kebab-case name modal.tsx exporting Modal.

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

Here is the part people most often get wrong: the DOM node moves; the React tree does not. A portaled component is still, for everything React cares about, a child of the component that rendered it. Its markup landed under <body>, but in the tree React reasons about, it never left home.

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

Context flows through it. A theme or auth provider sitting 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 is a topic for a couple of chapters from now; the only thing to take here is that a portal does not cut a component off from the providers above it.

State lives where it’s declared. Portaling does not create a new component scope. State declared in the parent still drives the portaled UI exactly as it would if the markup had rendered in place. The portal is a destination for DOM, not a boundary for anything on the React side.

Events bubble through the React tree, not the DOM tree. This is the surprising part. 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>. A real DOM click on the tooltip would never bubble to the div, since they aren’t ancestor and descendant anymore. But React replays the event along the tree it knows, the tree where the tooltip is still a child of the div, so the div’s onClick fires. This is deliberate: React keeps event propagation tied to the structure you wrote, so handlers behave the same no matter where the DOM physically landed.

This tree-stays-intact property is the whole point of portals, and it’s worth hearing argued from the architecture angle rather than 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 event-bubbling rule has one consequence worth naming. A common pattern is “click outside to close”: you put an onClick on a wrapper that closes a menu when anything outside the menu is clicked. If that menu is portaled and you rely on React’s bubbling, clicks inside the portaled menu still bubble up through the React tree to that wrapper, closing the menu the instant the user touches it. That’s the opposite of what you want. There are two fixes. You can call e.stopPropagation() inside the portal so the click doesn’t climb the React tree, or you can hang the outside-click listener on 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>
);

The responsibilities you inherit when you cover the page

Section titled “The responsibilities you inherit when you cover the page”

Step back and notice what you just did. You took a piece of UI out of the normal document flow and dropped it on top of the page. The browser has expectations about that, and they’re worth respecting, because they’re the same expectations a keyboard user and a screen-reader user are relying on.

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

That contract has a name and a canonical spec: the WAI-ARIA APG modal-dialog pattern. Here’s 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 of the page is now inert.

  2. aria-labelledby pointing at the title, aria-describedby at the body. These give the dialog an accessible name and description, 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 and never reach the page behind. Skip this and Tab walks the user straight out into the frozen content underneath, with no way back.

  5. Restore focus to the trigger on close. Otherwise focus snaps back 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. Your “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 genuinely fiddly to get right. The right move is the same one this lesson keeps returning to: you do not hand-build this. shadcn’s Dialog, with Radix underneath, ships every one of these guarantees correctly, today. Your job is not to reimplement the focus trap but 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. Reading the list is what matters here; the from-scratch focus trap and scroll lock are built up from primitives later, in the project chapter.

Here’s a quick audit to practice on. 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 you’ll meet is one of three shapes. You will import all three from a library rather than write them, but 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 regardless of what transform, overflow, or z-index traps sit between the trigger and the root. This is the central example of the lesson, generalized. The production answer is shadcn’s Dialog, AlertDialog, and Sheet (the side-anchored variant). You compose them; the portal and the whole 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 2026 shadcn default is sonner: you add a single <Toaster /> to the root layout and call toast('Saved') from anywhere. Because the container lives on <body>, toasts survive route changes, which is exactly what you want for a “your export is ready” message that lands after the user has navigated on.

Popovers, dropdowns, tooltips

These have to 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, the library Radix leans on, does the positioning math in JS, while CSS anchor positioning, covered just below, increasingly does it with no JS at all. You’ll meet the shadcn surface for these in a later chapter.

Portals plus manual focus and scroll management were always a workaround. They existed because the web platform lacked two things: a way to render above every stacking context, and a way to anchor one element to another without JavaScript. In 2026 the browser is closing both gaps, and reading new code well means knowing which problems are now the platform’s job.

Native <dialog> and showModal(). The <dialog> element has been Baseline since 2022 and sits near 97% support today. Call dialog.showModal() and the browser renders it in the top layer , above every stacking context. Because the top layer sits outside the normal painting order entirely, there’s no portal, no z-index to manage, and no containing-block trap. You also get Esc-to-close and a built-in focus trap for free, and the ::backdrop pseudo-element styles the scrim with plain CSS. That’s the platform doing natively most of what the portal-plus-accessibility dance used to do by hand.

The diagram below shows why the top layer makes z-index management for overlays obsolete.

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, and the choice is a real one:

  • Reach for native <dialog> + showModal() when you want platform behavior and standard styling is fine. 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 the open/closed state wired tightly into your app’s React state. Radix keeps the DOM mounted through the exit transition using the data-state pattern, so the modal can animate out before it unmounts, which raw <dialog> doesn’t orchestrate for you.

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

CSS anchor positioning is the platform’s answer to the popover-tracking problem, and it reached Baseline in 2026 (Chrome 125+, Firefox 132+, Safari 18.2+). You put anchor-name on the trigger, position-anchor and position-area on the popover, and @position-try to flip it when it would collide with a viewport edge. With that in place, the popover tracks its trigger with no JavaScript at all. Pair it with the popover attribute (and <button popovertarget> to wire a trigger to it), and you get a popover that renders in the top layer, never gets clipped by an ancestor’s overflow, and needs no portal. You’ll see the shadcn surface for this in a later chapter; here, just register that the JavaScript positioning math is becoming the browser’s job.

Here is the takeaway to hold onto. Portals, along with hand-rolled focus and scroll management, were the workaround for a platform that lacked a top layer and anchored positioning. In 2026 the platform is filling both gaps: <dialog>’s top layer removes the stacking and containing-block traps, and anchor positioning removes the popover math. Portals stay essential for what the platform doesn’t cover yet, namely arbitrary portaled content, animated overlays, and state controlled by your framework. They’re also still what shadcn ships, so you’ll read them daily. The difference now is that you read them as an experienced engineer, knowing which of those 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.