React Portals
In this page:
Why Portals Exist: The Overflow/Z-Index Problem
A modal nested deep inside a component with 'overflow: hidden' or a specific stacking context can get visually clipped or hidden behind other elements, no matter how high you set its z-index. Portals solve this by rendering the modal's actual DOM elsewhere in the page, escaping that parent's CSS constraints entirely.
Note: A common setup is a dedicated '#modal-root' <div> placed directly under <body> in your HTML, completely outside your main app's DOM structure.
Warning: Without a portal, a deeply-nested modal can be invisibly clipped by an ancestor's overflow or stacking rules, even though the code looks correct.
Example: Why Portals Exist: The Overflow/Z-Index Problem
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function Modal({ children }) {
return ReactDOM.createPortal(
<div style={{ position: "fixed", top: 20, left: 20, background: "white", border: "1px solid #333", padding: "16px" }}>
{children}
</div>,
document.body
);
}
function App() {
return <div><p>Main app content</p><Modal><p>I'm rendered via a portal, into document.body</p></Modal></div>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Portals Still Follow the React Tree for Events
Even though a portal's actual DOM elements live somewhere else in the page, React still treats them as part of the original component tree for props, context, and event bubbling. A click inside a portal's content will still bubble up through its REACT ancestors, not its DOM ancestors.
Note: This means Context values from a parent still flow correctly into a portal's content, exactly as if it hadn't been portaled at all.
Warning: Don't assume clicking outside a portal (in the DOM sense) means clicking outside it in React's event system — the event bubbling follows React's tree, which can surprise you.
Example: Portals Still Follow the React Tree for Events
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const ThemeContext = React.createContext("light");
function Modal({ children }) {
return ReactDOM.createPortal(<div style={{border: "1px solid #333", padding: "8px"}}>{children}</div>, document.body);
}
function ModalContent() {
const theme = React.useContext(ThemeContext);
return <p>Theme inside portal: {theme}</p>;
}
function App() {
return <ThemeContext.Provider value="dark"><Modal><ModalContent /></Modal></ThemeContext.Provider>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
The Target Node Must Already Exist
createPortal's second argument must be a real, already-existing DOM node — React doesn't create it for you. This target is usually a plain <div> already present in your HTML (like index.html), separate from React's own root element.
Note: document.body itself is a valid, always-existing target if you don't want to add a separate dedicated div.
Warning: Passing a DOM node that doesn't exist yet (like one that hasn't rendered, or a typo'd ID) throws an error the moment createPortal tries to use it.
Example: The Target Node Must Already Exist
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function TooltipPortal({ children }) {
return ReactDOM.createPortal(
<div style={{ position: "fixed", bottom: 20, right: 20, background: "black", color: "white", padding: "8px" }}>
{children}
</div>,
document.body // always exists, safe target
);
}
function App() {
return <TooltipPortal><span>I'm a portaled tooltip</span></TooltipPortal>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
- Forgetting the target DOM node passed to createPortal must actually exist in the HTML before the portal tries to render into it.
- Assuming a portal breaks React's component tree for events too — event bubbling still follows the React tree, not the DOM tree.
- Creating a brand-new target DOM node on every render instead of reusing a stable one.
- ReactDOM.createPortal(children, domNode) renders children into a different part of the actual DOM.
- This is commonly used for modals, tooltips, and dropdowns that need to escape a parent's overflow:hidden or z-index stacking context.
- Despite rendering elsewhere in the DOM, a portal's content still behaves as a normal part of the React component tree for state, context, and event bubbling.
- The target DOM node must already exist (usually a dedicated <div> in index.html) before createPortal can render into it.
Available since React 16.0 (Portals introduced).
Chapter Quiz — Complete all 14 topics to unlock
0/14 topics done
Complete these topics first:
- Error Boundaries
- React Portals
- Modals using Portals (practical use)
- React Suspense
- Code Splitting with React.lazy
- Introduction to Server Components
- Introduction to Next.js (server-side React)
- Using React with TypeScript
- Scalable Folder Architecture
- Common React Design Patterns
- Component Documentation with Storybook
- Accessibility (a11y) in React
- i18n with react-i18next
- React Security Best Practices