React Portals: The Escape Hatch You're Already Using
React Portals let you render components outside the DOM hierarchy while preserving React's event system. You're probably already using them through Radix and Shadcn without realizing it.
Every React component renders inside its parent. That is the entire mental model. Parent renders child, child renders grandchild, and the DOM tree mirrors the component tree. Until it doesn't work.
Modals, tooltips, dropdown menus, toasts. These all need to visually break free from the DOM hierarchy they live in. You build a modal inside some deeply nested card component, and suddenly it is getting clipped by an overflow: hidden three levels up, or buried under a sibling's z-index. You set z-index: 9999 and pray. That is not engineering. That is negotiation with CSS. React Portals solve this properly.
The Actual Problem Portals Solve
This is not about convenience. It is about a real CSS limitation called stacking contexts.
When a parent element creates a new stacking context (through position: relative with a z-index, or transform, or opacity less than 1, or a dozen other triggers), every child inside that context is trapped. No amount of z-index on the child will let it paint above something outside the parent's stacking context.
Here is a stripped-down example that breaks:
function App() {
const [showModal, setShowModal] = useState(false);
return (
{/* This wrapper creates a stacking context */}
<div style={{ position: 'relative', zIndex: 1 }}>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
{/* This will paint ABOVE the modal, no matter what */}
<div style={{ position: 'relative', zIndex: 2, background: 'red' }}>
I will cover your modal. Good luck.
</div>
);
}Your modal lives inside a z-index: 1 stacking context. The red div sits at z-index: 2. Your modal cannot escape. Even z-index: 999999 on the modal does nothing. It is scoped to its parent context.
This is not a React problem. It is a CSS problem. But React Portals are the React-idiomatic way to solve it.
Stacking Contexts: The CSS Trap Nobody Explains Well
Before we get into the portal API, it is worth understanding the CSS mechanic that makes portals necessary. If you already know stacking contexts well, skip ahead. But most frontend developers have a shaky mental model of this, and it is the root cause of almost every "why is my modal behind that div" bug.
A stacking context is a three-dimensional conceptualization of HTML elements along an imaginary z-axis. Think of it as a group. Every element inside that group competes for z-order only against other elements in the same group. The group itself has a position in its parent's stacking order, and nothing inside can break out of it.
The root <html> element creates the top-level stacking context. Everything on the page starts in this one. But certain CSS properties on an element cause it to create a new stacking context, and its children are now scoped to that new context.
Here is what creates a new stacking context:
position: absoluteorposition: relativecombined with az-indexvalue other thanautoposition: fixedorposition: sticky(always, noz-indexneeded)opacityless than1transformset to anything other thannonefilterset to anything other thannonewill-changeset toopacity,transform, or similarisolation: isolatecontain: layoutorcontain: paint- Elements that are flex or grid children with a
z-indexother thanauto
That list is longer than most people expect. And this is why stacking bugs feel random. You add opacity: 0.99 for a fade animation on a parent div, and suddenly your modal is trapped. You add transform: translateZ(0) as a GPU acceleration hack, and your tooltip disappears behind a sibling. The trigger is often something you did not think would affect stacking at all.
Here is a concrete example:
.sidebar {
position: relative;
z-index: 2;
}
.main-content {
position: relative;
z-index: 1;
}
.main-content .modal-overlay {
position: fixed;
z-index: 99999;
}The modal overlay has z-index: 99999. Feels like it should win. But .main-content created a stacking context at z-index: 1. The modal is inside that context. The sidebar is at z-index: 2 in the parent context. The browser compares 1 vs 2 at the parent level. The sidebar wins. The 99999 on the modal is only meaningful within the z-index: 1 group, and the browser never even considers it against the sidebar.
You can visualize it like nested folders:
Root stacking context
├── .sidebar (z-index: 2)
│ └── (its children compete here)
└── .main-content (z-index: 1)
└── .modal-overlay (z-index: 99999) ← trapped in here
The modal's 99999 only matters inside the .main-content folder. At the root level, .main-content is a 1 and .sidebar is a 2. Game over.
The fix without portals is to restructure your DOM so the modal is not inside the offending stacking context. But in React, your component tree dictates your DOM tree. If the modal state lives in a component that is nested three levels deep inside a layout with stacking contexts, you are stuck. You would have to lift state up, pass callbacks around, or use global state just to render a modal somewhere else in the DOM.
Portals let you keep the modal component exactly where it makes sense in your React tree while rendering its output outside the stacking context entirely. That is the whole point.
How Portals Work
The API is small. One function: createPortal from react-dom.
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.getElementById('portal-root')
);
}Two arguments:
- The JSX you want to render. This is your modal, tooltip, or whatever needs to break free.
- The DOM node you want to render it into. This is typically a separate div you add to your
index.html.
Your index.html needs that target node:
<body>
<div id="root"></div>
<div id="portal-root"></div>
</body>That is it. Your modal JSX is defined inside your component tree (so it has access to all props, state, and context), but it renders into portal-root instead of root. It physically lives outside the stacking context that was trapping it.
The Part Most People Miss: Event Bubbling Still Works
This is the thing that separates createPortal from just doing a raw ReactDOM.render into another node.
When you portal a component, React maintains the logical parent-child relationship in its own virtual tree, even though the DOM structure says otherwise. Events bubble up through the React tree, not the DOM tree.
function App() {
const handleClick = () => {
console.log('Parent caught the click');
};
return (
<div onClick={handleClick}>
<Modal open={true} onClose={() => {}}>
<button>Click me</button>
</Modal>
</div>
);
}Click the button inside the portaled modal. The onClick on the parent div fires. In the DOM, the modal is in a completely separate subtree under portal-root. But React still bubbles the synthetic event up through the component tree as if the modal were a direct child.
This matters for things like:
- Form context providers that wrap a modal trigger and need to also wrap the modal content
- Click-outside detection patterns that rely on event propagation
- Error boundaries that should catch errors from portaled children
- Any context (
useContext) that the portaled component needs access to
If you instead rendered with a standalone createRoot, you would lose all of this. The component would be an island. No shared context, no event delegation, no error boundary coverage.
Building a Proper Modal with Portals
Let me put together something more complete. A reusable modal that handles overlay clicks, escape key, and scroll locking.
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
const handleEscape = useCallback((e) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
if (!open) return;
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [open, handleEscape]);
if (!open) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
background: 'white',
borderRadius: '8px',
padding: '24px',
maxWidth: '500px',
width: '100%',
}}
>
{children}
</div>
</div>,
document.getElementById('portal-root')
);
}A few things to note:
- Overlay click closes the modal. The
onClickon the overlay callsonClose, butstopPropagationon the content div prevents clicks inside the modal from closing it. - Escape key is handled. The
keydownlistener is added when the modal opens and cleaned up when it closes or unmounts. - Scroll lock. Setting
overflow: hiddenon the body prevents the page from scrolling while the modal is open. The cleanup function restores it.
Usage stays simple:
function ProfilePage() {
const [editing, setEditing] = useState(false);
return (
<div>
<h1>Profile</h1>
<button onClick={() => setEditing(true)}>Edit Profile</button>
<Modal open={editing} onClose={() => setEditing(false)}>
<h2>Edit Profile</h2>
<form>
<input placeholder="Display name" />
<button type="submit">Save</button>
</form>
</Modal>
</div>
);
}The modal definition sits right next to the button that triggers it. The logic is colocated. But the rendered output lives in portal-root, safely outside any stacking context issues.
Portals Beyond Modals
Modals get all the portal press. But the same pattern applies anywhere you need to render outside the current DOM position.
Tooltips. A tooltip on a button inside a scrollable container with overflow: hidden will get clipped. Portal it to the body and position it with position: fixed using coordinates from getBoundingClientRect.
Dropdown menus. Same clipping issue. If your dropdown lives inside a card with overflow: hidden or a parent with a lower z-index, the menu gets cut off. Portaling it out solves this.
Toasts and notifications. These should always render at a consistent position (usually top-right or bottom-right of the viewport), regardless of which component triggers them. A portal to a dedicated toast container makes this straightforward.
Floating toolbars. Rich text editors often have floating formatting bars that need to appear above everything. Portal.
The common thread: whenever a UI element needs to visually escape its DOM ancestors, a portal is the right tool.
You Are Probably Already Using Portals
If you use Radix UI or Shadcn/ui, you are using portals constantly without writing a single createPortal call.
Open up a Shadcn Dialog component. Under the hood, it uses Radix's Dialog primitive. Radix's Dialog.Content renders through a portal by default. Same with Dialog.Overlay. When you write this:
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<p>Your content here.</p>
</DialogContent>
</Dialog>That DialogContent is not rendering where you put it in the JSX. Radix portals it to the end of document.body. Inspect the DOM next time you open a Shadcn dialog. You will see the overlay and content sitting outside your app's root div, appended to the body.
The same goes for:
DropdownMenu.Contentin Radix (and by extension Shadcn'sDropdownMenu)Tooltip.ContentPopover.ContentSelect.ContentContextMenu.ContentAlertDialog.Content
Every one of these portals out by default. That is why your Shadcn dropdown menus never get clipped by parent containers. That is why your tooltips always float above everything. It is not magic CSS. It is portals doing the work for you.
Radix even exposes a container prop on most of these if you want to control where the portal renders to:
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogPortal container={customContainerRef.current}>
<DialogContent>Portaled to a custom node</DialogContent>
</DialogPortal>
</Dialog>And if for some reason you do not want portal behavior (rare, but it comes up in iframes or shadow DOM situations), Radix lets you disable it. Headless UI from Tailwind Labs does the same thing. So does Reach UI. Portals are the standard approach for any component library that renders overlay-style content.
When Not to Use Portals
Portals are not free. A few things to watch for.
Server-side rendering. document.getElementById('portal-root') does not exist on the server. You need to guard portal creation behind a client-side check or use a ref that gets set after mount. Most frameworks handle this for you, but if you are writing a portal from scratch, wrap it:
function Modal({ open, onClose, children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!open || !mounted) return null;
return createPortal(
<div className="modal-overlay">{children}</div>,
document.getElementById('portal-root')
);
}Accessibility. Portaling content to the end of the body means screen readers encounter it in a different order than the visual layout suggests. You need to manage focus correctly. When a modal opens, focus should move into it. When it closes, focus should return to the trigger element. aria-modal="true" and proper role="dialog" attributes are necessary. This is another reason to use Radix or Headless UI. They handle this for you.
Style isolation. If your app uses scoped styles (CSS Modules, Styled Components with a specific provider, or a shadow DOM), portaled content might render outside the scope of those styles. The portal target node may not inherit the same CSS context. Test your styles when portaling to a different part of the document.
Multiple portals and stacking. If you have two modals open at once (a confirmation dialog inside a settings modal, for example), you need to manage which one is on top. This usually means incrementing z-index values or keeping a stack. Again, Radix handles this. If you are building from scratch, think about it before you get surprised.
React Portals do one thing: render a component into a different DOM node while keeping it in the React tree. The API is a single function. The concept is straightforward. But the problems it solves (stacking context traps, overflow clipping, z-index wars) are ones that burn hours of debugging time when you try to work around them with CSS alone. If you are reaching for z-index: 9999 and hoping for the best, stop. Use a portal. Or better yet, recognize that your component library is already using one.