React Portals and Modals: A Practical Tutorial
Learn how React portals work, when to use them, and how to build accessible modals that escape parent overflow and z-index traps.
What you'll learn
- ✓What a React portal is and how it changes the DOM tree
- ✓Why modals so often need portals
- ✓How event bubbling still works through portals
- ✓How to add focus trapping and ESC handling
- ✓Pitfalls with SSR and hydration
Prerequisites
- •Comfortable with JS and HTML
- •Basic React component knowledge
What and Why
A React portal renders a child into a DOM node that lives outside the parent component’s DOM hierarchy. The React tree and event system stay the same, but the actual HTML output moves elsewhere, usually to a sibling of body.
Why bother? Modals, tooltips, popovers, and toasts all suffer from the same problem: they need to visually break out of the page layout. A modal trapped inside a parent with overflow: hidden will be clipped. A tooltip inside a card with transform will inherit a new stacking context and might get covered. Portals solve these problems by escaping the offending parent entirely.
Mental Model
Imagine React maintains two parallel realities. One is the component tree, the parent-child relationships your code expresses. The other is the DOM tree, what the browser actually paints. Most of the time these match. A portal lets them disagree on purpose.
The component still receives props from its parent. Events still bubble up to the parent’s handlers. Context still flows down. But the rendered HTML lives somewhere else. To the browser, the modal is a child of body. To React, it is still a child of <Page>.
React tree: DOM tree:
App body
| |-- div#root
Page | |-- App markup
| |-- div#portal-root
Modal* |-- Modal markup
* Modal portals out, but events bubble to Page in React land. Hands-on Example
First, add a target div in your HTML, often in your root layout:
<div id="portal-root"></div>
Then a minimal modal component:
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
useEffect(() => {
function onKey(e) {
if (e.key === 'Escape') onClose();
}
if (open) window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const target = document.getElementById('portal-root');
return createPortal(
<div className="overlay" onClick={onClose}>
<div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
target
);
}
This portal sits visually on top of everything, ignores parent overflow, listens for ESC, and closes when you click the backdrop. Note that even though the modal is a sibling of body in the DOM, the React onClick still bubbles up to whatever parent component rendered the <Modal>.
For accessibility, you also need focus trapping: when the modal opens, focus moves inside, and tabbing wraps. Libraries like @radix-ui/react-dialog and react-aria handle this for you and are worth using in production.
Common Pitfalls
The biggest gotcha is server-side rendering. document does not exist on the server, so getElementById blows up. Wrap your portal logic in a useEffect or render null until mounted. Frameworks like Next.js and Astro often add a tiny useIsMounted hook for this exact reason.
Another trap is forgetting to stop event propagation. If your backdrop closes the modal on click and you do not stop propagation on the inner card, clicking inside the modal also closes it. Use stopPropagation or check event.target === event.currentTarget.
People also confuse the React tree with the DOM tree. CSS selectors like .parent .child no longer match because the child has moved. If your modal’s styles break after portaling, the problem is usually inherited CSS being lost.
Finally, never forget aria-modal, a focus trap, and returning focus to the trigger on close. A portal alone does not give you an accessible modal.
Best Practices
- Create a single portal root in your root layout, not one per modal.
- Lock body scroll while the modal is open by adding
overflow: hiddentohtml. - Use a well-tested dialog library when shipping to users with disabilities.
- Make sure the modal’s z-index is high but bounded; do not race to
99999. - Animate enter and exit on the inner card, not the portal target itself.
Wrap-up
Portals are the cleanest answer to the question, “how do I escape my parent’s box?” They keep your component tree tidy while giving you full visual freedom. Combined with proper accessibility, they let you build modals, popovers, and toasts that behave correctly across every browser. Master them and you will never fight overflow: hidden again.
Related articles
- React React Modal and Overlay Patterns
Build accessible, composable modals and overlays in React using portals, focus traps, and headless primitives — with pitfalls around scroll lock, stacking, and a11y.
- Tailwind Tailwind with Headless UI Tutorial
Use Headless UI's accessible React components alongside Tailwind utilities to build menus, dialogs, comboboxes, and toggles that match your design system exactly.
- Tailwind Tailwind with Radix UI Tutorial
Combine Radix UI's accessible unstyled primitives with Tailwind utilities to build production-grade dialogs, menus, and tooltips that look exactly the way you want.
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.