React Portals
React portals let you render a component's output into a DOM node that lives outside its parent's DOM hierarchy while keeping it fully inside the same React component tree.
What a Portal Actually Does
Normally, when a component renders JSX, React inserts the resulting DOM nodes as children of its parent's DOM node. A portal breaks that link: the JSX still belongs to the same React component tree — it still receives props, context, and state updates from its logical parent — but the actual DOM nodes it produces are attached somewhere else entirely, such as a <div id="modal-root"> sitting directly under <body>.
Why You'd Want to Escape the DOM Tree
The most common reason is CSS. If a component lives deep inside a container with overflow: hidden, a transform, or a low z-index, anything you render inside it — like a modal or tooltip — inherits those constraints and can get clipped or hidden behind other elements. Rendering that overlay through a portal into a sibling of <body> sidesteps the problem completely, because the DOM node is no longer a descendant of the clipping container.
- Modal dialogs and confirmation prompts
- Tooltips and popovers that must escape clipped containers
- Toast and notification stacks anchored to the viewport
- Dropdown menus that need to overlay unrelated page sections
- Full-screen loading overlays
A Basic Modal Portal
import { useState } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById('modal-root')
);
}
function App() {
const [open, setOpen] = useState(false);
return (
<div className="app">
<button onClick={() => setOpen(true)}>Open Modal</button>
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Hello from a portal</h2>
<p>This markup lives outside #app in the DOM.</p>
</Modal>
<div id="modal-root"></div>
</div>
);
}
export default App;
Notice that Modal still looks like an ordinary component from the outside — you mount it, pass it props, and it disappears when isOpen is false. The only difference is where its markup physically lands in the DOM. createPortal takes two arguments: the JSX you want to render, and the target DOM node to render it into.
Detecting Clicks Outside via Bubbling
import { useState } from 'react';
import { createPortal } from 'react-dom';
function Dropdown({ label, children }) {
const [open, setOpen] = useState(false);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button onClick={() => setOpen(true)}>{label}</button>
{open &&
createPortal(
<ul
className="dropdown-menu"
onClick={(e) => {
// This click physically happens inside document.body,
// but React still delivers it through this component's
// own event handlers, because portals only change DOM
// placement, not the React tree.
e.stopPropagation();
setOpen(false);
}}
>
{children}
</ul>,
document.body
)}
</div>
);
}
export default function App() {
return (
<div style={{ padding: 20 }}>
<Dropdown label="Options">
<li>Item 1</li>
<li>Item 2</li>
</Dropdown>
</div>
);
}
A Reusable Modal Component
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function useEscapeKey(onEscape) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === 'Escape') onEscape();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onEscape]);
}
function ConfirmModal({ isOpen, title, onConfirm, onCancel }) {
const dialogRef = useRef(null);
useEscapeKey(onCancel);
useEffect(() => {
if (isOpen) dialogRef.current?.focus();
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div
ref={dialogRef}
className="modal-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<h2>{title}</h2>
<button onClick={onConfirm}>Confirm</button>
<button onClick={onCancel}>Cancel</button>
</div>
</div>,
document.getElementById('modal-root')
);
}
export default function App() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>Delete item</button>
<ConfirmModal
isOpen={open}
title="Are you sure?"
onConfirm={() => {
console.log('Confirmed');
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
<div id="modal-root"></div>
</div>
);
}
Exercise: React Portals
What does ReactDOM.createPortal fundamentally let you do?