Learn how to render React components outside their parent DOM hierarchy using portals — essential for modals, tooltips, and dropdown menus that need to break out of overflow or z-index constraints.
Normally, when you render a child component in React, it gets mounted into the DOM as a child of the nearest parent DOM node:
function Parent() {
return (
<div className="parent">
<Child />
</div>
);
}This means <Child /> will always be rendered inside the .parent div in the actual DOM. Most of the time, this is exactly what you want. But sometimes, you need a component to render outside its parent's DOM hierarchy.
Portals let you render a child into a different DOM node, anywhere in the document, while keeping it as part of the React component tree. The component stays connected to its parent for context, events, and state — but its actual DOM output lives somewhere else.
ReactDOM.createPortal(child, domNode)child — Any renderable React element (JSX, string, fragment, etc.)domNode — The DOM element where the child should be mountedThis is one of those APIs that sounds obscure until you hit the exact problem it solves — then it becomes indispensable.
The most common reason for using portals is the overflow/z-index problem with modals, tooltips, and dropdown menus.
Consider this structure:
<div class="card" style="overflow: hidden; position: relative;">
<button>Open Menu</button>
<div class="dropdown-menu">
<!-- This gets clipped by overflow: hidden! -->
</div>
</div>If the parent has overflow: hidden, your dropdown menu gets cut off. If it has a low z-index, your modal might appear behind other elements. These are CSS layout constraints that you cannot fix by just adding more CSS — you need the element to live outside the constraining parent in the DOM.
Without portals, you would need to:
Portals solve this elegantly — your modal component stays logically where it belongs in the React tree (inside the card component), but its DOM output renders at the top level of the document.
Common use cases for portals:
To use a portal, you need two things: a target DOM node and a call to ReactDOM.createPortal().
Step 1: Create a target DOM node. In your HTML, add a separate container alongside your React root:
<div id="root"></div>
<div id="modal-root"></div>Step 2: Create a portal component.
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.getElementById('modal-root')
);
}Step 3: Use it like any other component.
function App() {
const [showModal, setShowModal] = React.useState(false);
return (
<div>
<h1>My App</h1>
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal isOpen={showModal}>
<h2>Modal Title</h2>
<p>This is rendered in #modal-root, not in #root!</p>
<button onClick={() => setShowModal(false)}>Close</button>
</Modal>
</div>
);
}Even though <Modal> is used inside <App>, the actual <div className="modal-overlay"> element will appear inside #modal-root in the DOM, completely outside the #root element.
You can verify this by inspecting the DOM — the modal content will be a child of #modal-root, not #root.
One of the most powerful (and sometimes surprising) features of portals is that events bubble through the React component tree, not the DOM tree.
This means that even though a portal's DOM output lives in a different part of the document, events fired inside the portal will still bubble up through the React component hierarchy as if the portal were a regular child.
function Parent() {
const handleClick = (e) => {
// This WILL fire when clicking inside the portal!
console.log('Parent caught click:', e.target);
};
return (
<div onClick={handleClick}>
<h1>Parent Component</h1>
<Modal isOpen={true}>
<button>Click me (I am in a portal)</button>
</Modal>
</div>
);
}When the user clicks the button inside the portal, the click event bubbles up through React's component tree to the <div onClick={handleClick}> in Parent — even though the button is not a DOM descendant of that div.
This behavior is intentional and very useful:
However, this can also cause unexpected behavior. If a parent component has a click handler that stops propagation, it will affect portal events too. Keep this in mind when debugging event-related issues with portals.
A production-ready modal needs more than just a portal. Accessibility requirements include:
1. Focus trapping: When the modal opens, focus should move into it and stay there until it closes. Tab and Shift+Tab should cycle through focusable elements inside the modal.
2. Escape key: Pressing Escape should close the modal.
3. ARIA attributes: The modal needs role="dialog", aria-modal="true", and aria-labelledby pointing to its heading.
4. Background scroll lock: The page behind the modal should not scroll.
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = React.useRef(null);
React.useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
modalRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.getElementById('modal-root')
);
}The onClick on the overlay closes the modal when clicking outside, while e.stopPropagation() on the modal content prevents closing when clicking inside it. This is a standard pattern used by most modal libraries.
<div id="root"></div>
<div id="modal-root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
function Modal({ children }) {
return ReactDOM.createPortal(
<div id="modal" className="modal"
style={{ padding: "20px", background: "#222", border: "1px solid #555", borderRadius: "8px" }}>
{children}
</div>,
document.getElementById('modal-root')
);
}
function App() {
return (
<div>
<h1>App Content (inside #root)</h1>
<p>The modal below is rendered into #modal-root via a portal:</p>
<Modal>
<p>Modal Content</p>
<p>I live in #modal-root, not #root!</p>
</Modal>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>When using a portal, where do React events bubble to?