Portals
Normally, React renders a component into its parent's DOM node. A portal lets you render a component's output into a completely different DOM node — one that lives outside the component's DOM ancestor hierarchy. The component still behaves as if it is inside the React tree (events bubble, context works), but its DOM lives elsewhere.
import { createPortal } from 'react-dom'
function Modal({ children }) {
// Renders children into document.body, not into the component's parent DOM node
return createPortal(
children,
document.body
)
}Why Portals Exist
The classic problem: you build a modal or tooltip inside a deeply nested component. The parent somewhere up the tree has overflow: hidden or a z-index stacking context that clips or obscures your overlay. Even if you set z-index: 9999, it cannot escape the stacking context created by a parent with transform, filter, or will-change CSS properties.
Portals solve this by rendering the overlay directly into document.body or another top-level container, bypassing the CSS constraints of any ancestor element.
Building a Modal with a Portal
import { createPortal } from 'react-dom'
import { useEffect, useRef } from 'react'
function Modal({ isOpen, onClose, title, children }) {
const overlayRef = useRef(null)
// Lock body scroll while modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
// Close when clicking the backdrop
function handleOverlayClick(e) {
if (e.target === overlayRef.current) onClose()
}
if (!isOpen) return null
return createPortal(
<div
ref={overlayRef}
onClick={handleOverlayClick}
style={{
position: 'fixed',
inset: 0, // shorthand for top/right/bottom/left: 0
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
style={{
background: 'white',
borderRadius: 8,
padding: '2rem',
maxWidth: 600,
width: '90%',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">×</button>
</div>
{children}
</div>
</div>,
document.body // ← portal target: renders outside the component hierarchy
)
}
// Usage — the modal is logically inside <App> but DOM-wise lives on <body>
function App() {
const [open, setOpen] = React.useState(false)
return (
<div style={{ overflow: 'hidden', height: 200 }}>
{/* overflow: hidden here would clip a non-portal modal */}
<button onClick={() => setOpen(true)}>Open Modal</button>
<Modal isOpen={open} onClose={() => setOpen(false)} title="Hello">
<p>This modal renders on document.body, escaping overflow: hidden.</p>
</Modal>
</div>
)
}Portals Stay Inside the React Tree
Despite the DOM relocation, a portal is still a child of the component that renders it from React's perspective. This means:
Context propagates — a portal inside a
<ThemeProvider>still receives the theme, even though the DOM node is on<body>Events bubble through the React tree — a click inside the portal bubbles up through the React component ancestors, not through the DOM ancestors
State and lifecycle — the portal mounts and unmounts with its parent component, not independently
// Event bubbling through the React tree — not the DOM tree
function Parent() {
function handleClick() {
// This fires when the button inside the portal is clicked!
// Even though the button's DOM parent is document.body, not this <div>
console.log('clicked inside portal')
}
return (
// onClick on this div catches portal events via React's synthetic event system
<div onClick={handleClick}>
<Modal>
<button>Click me</button> {/* DOM: child of body; React: child of Parent */}
</Modal>
</div>
)
}Creating a Dedicated Portal Container
Instead of targeting document.body directly, many apps create a dedicated <div id="portal-root"> in the HTML for overlays. This makes the DOM structure cleaner and simplifies CSS targeting:
<!-- public/index.html or app layout --> <body> <div id="root"></div> <div id="portal-root"></div> <!-- portals render here --> </body>
// Use the dedicated container instead of document.body
createPortal(children, document.getElementById('portal-root'))
// Or create one lazily if it doesn't exist
function getPortalRoot() {
let el = document.getElementById('portal-root')
if (!el) {
el = document.createElement('div')
el.id = 'portal-root'
document.body.appendChild(el)
}
return el
}Common Portal Use Cases
Modals and dialogs — need to escape stacking contexts and overlay the entire page
Tooltips and popovers — must appear above all other content regardless of parent z-index
Dropdown menus — need to overflow parents with
overflow: hidden(e.g., table cells)Toast notifications — floating messages anchored to a screen corner
Full-screen overlays — loading spinners, lightboxes, image viewers