React Portals
React Portals render a component's output into a different DOM node than its parent — while still keeping it as a logical child in the React component tree. Portals are the solution when you need elements like modals, tooltips, and dropdown menus to visually escape their container but still behave as React children.
The Problem Portals Solve
CSS properties like overflow: hidden, z-index, and position: relative on a parent element affect all children. A modal rendered inside a card component gets clipped by the card's overflow: hidden rule.
Diagram: The clipping problem
<div style="overflow: hidden; height: 200px"> ← Card container
<h3>Product Name</h3>
<p>Description</p>
<Modal /> ← Modal is INSIDE the card
The modal gets cut off at 200px height!
overflow: hidden clips it.
</div>
Diagram: With a Portal
<div style="overflow: hidden; height: 200px"> ← Card container
<h3>Product Name</h3>
<p>Description</p>
{/* Modal renders here in React logic... */}
</div>
<div id="modal-root"> ← Appended to document.body
<Modal /> ← Portal sends Modal here in the DOM
No overflow clipping, full-screen overlay works!
</div>
Creating a Portal
Use ReactDOM.createPortal(children, domNode). The first argument is the JSX to render. The second is the DOM element to render it into.
import ReactDOM from "react-dom";
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div style={{
position: "fixed",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}>
<div style={{
background: "white",
padding: "32px",
borderRadius: "8px",
minWidth: "320px",
}}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body // Render directly into document.body
);
}
Setting Up a Dedicated Portal Container
Add a dedicated container div in your HTML file for portals. This keeps portal-rendered content organized and separate from the main React root.
<!-- index.html -->
<body>
<div id="root"></div> <!-- Main React app -->
<div id="modal-root"></div> <!-- Portal target -->
</body>
// Use the dedicated container
return ReactDOM.createPortal(
<div className="modal-overlay">...</div>,
document.getElementById("modal-root")
);
Using the Modal
function ProductCard({ product }) {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ overflow: "hidden", height: "150px" }}>
<h3>{product.name}</h3>
<button onClick={() => setShowModal(true)}>View Details</button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<h2>{product.name}</h2>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</Modal>
</div>
);
}
The Modal component is a React child of ProductCard, but its DOM output lands in document.body — completely outside the card's overflow container.
Events Still Bubble Through the React Tree
Even though a portal renders outside the parent DOM, events inside the portal bubble up through the React component tree as if they were inside the parent. This keeps React's event system consistent.
function App() {
const handleClick = () => {
console.log("App clicked"); // This fires when modal content is clicked!
};
return (
<div onClick={handleClick}>
<Modal /> {/* Modal is a portal, but click events bubble to App */}
</div>
);
}
This behavior is intentional. It means you can handle portal events in parent components just like any other child.
Common Portal Use Cases
Use Case Why Portal Helps
-----------------------------------------------------
Modal / Dialog Escape overflow:hidden on parent containers
Tooltip Position above all other elements with z-index
Dropdown Menu Avoid clipping by scrollable containers
Toast Notifications Always appear at edge of screen regardless of page structure
Drawer / Side Panel Full-height overlay outside layout constraints
Reusable Portal Component
import { useEffect, useState } from "react";
import ReactDOM from "react-dom";
function Portal({ children, containerId = "portal-root" }) {
const [container, setContainer] = useState(null);
useEffect(() => {
let el = document.getElementById(containerId);
if (!el) {
el = document.createElement("div");
el.id = containerId;
document.body.appendChild(el);
}
setContainer(el);
return () => {
// Cleanup: remove dynamically created container
if (el.childNodes.length === 0) {
document.body.removeChild(el);
}
};
}, [containerId]);
if (!container) return null;
return ReactDOM.createPortal(children, container);
}
// Usage
function Tooltip({ text, children }) {
const [visible, setVisible] = useState(false);
return (
<span
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{children}
{visible && (
<Portal>
<div style={{ position: "fixed", background: "#333", color: "#fff", padding: "6px 10px", borderRadius: "4px" }}>
{text}
</div>
</Portal>
)}
</span>
);
}
Closing a Modal on Escape Key
Add keyboard support to portals for accessibility.
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
Summary
Portals render JSX into a DOM node outside the parent component's DOM hierarchy while keeping the component logically inside the React tree. Use ReactDOM.createPortal(jsx, domNode) to create one. Add a dedicated portal-root div in your HTML for clean organization. Events from portal content bubble through the React tree normally. Portals solve clipping and stacking context problems for modals, tooltips, dropdowns, and overlays — any UI that must visually escape its container's CSS constraints.
