Understand React Portals — how createPortal teleports DOM output outside the component's parent while keeping it inside the React tree, why this solves stacking context problems, and how events still bubble correctly.
A React Portal teleports a component's DOM output to a different node — typically document.body — while keeping it fully inside the React component tree. The component still belongs to its parent in React's world: events bubble up through React ancestors normally, Context still works, and the component's lifecycle is tied to its React parent. But the actual DOM nodes materialise somewhere else entirely. This solves the z-index and overflow stacking context problems that make modals, dropdowns, and tooltips so painful to build.
The classic scenario: you build a modal inside a component that has overflow: hidden or a low z-index. No matter how high you set the modal's z-index, the browser clips it because it's constrained by its parent's stacking context. The modal appears behind other elements or gets cut off.
/* Parent has overflow: hidden — modal gets clipped regardless of z-index */
.card {
overflow: hidden;
position: relative;
}
/* This modal is trapped inside .card even with z-index: 9999 */
.modal {
position: absolute;
z-index: 9999;
}
The fundamental fix is to render the modal's DOM outside the constrained parent — in document.body, where it's unrestricted. That's exactly what Portals do.
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
// Renders children into document.body — outside the current DOM subtree
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body // ← the target DOM node; can be any existing DOM element
);
}
createPortal takes two arguments: the React children to render, and the target DOM node to render them into. The children are normal React — JSX, components, event handlers, the works.
For modals and overlays, document.body works fine. For more structured apps, add a dedicated container in your HTML to keep things organised:
<!-- public/index.html -->
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- portal target -->
</body>
// Use it in any component
return createPortal(
<ModalContent />,
document.getElementById('modal-root')
);
Even though the modal's DOM nodes live in document.body, events fired inside the portal bubble through the React component tree — not the DOM tree. This means:
function Parent() {
function handleClick() {
console.log('Parent caught the click!'); // This DOES fire even though
} // modal is outside Parent in the DOM
return (
<div onClick={handleClick}>
<p>I am Parent</p>
<Modal /> {/* portal renders in document.body, but events bubble to Parent */}
</div>
);
}
Portal events bubble through the React tree, not the DOM tree. This is usually what you want — a modal that's a child of a form component can still bubble submit events up to the form.
Portals require explicit accessibility work that normal components don't:
focus-trap-react rather than implementing this manually.role="dialog" and an accessible title via aria-labelledby.<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Confirm Delete</h2>
...
</div>
If you dynamically create the portal target (rather than using a pre-existing DOM node), clean it up when the component unmounts:
function Modal({ children }) {
const [container] = useState(() => {
const el = document.createElement('div');
document.body.appendChild(el);
return el;
});
useEffect(() => {
return () => document.body.removeChild(container); // cleanup on unmount
}, [container]);
return createPortal(children, container);
}Many developers think portals break the React component tree — portals only change where the DOM nodes appear. In React's tree, a portal's children are still children of the component that rendered the portal. Context, error boundaries, and event bubbling all work through the React tree, not the DOM position.
Many developers think events inside a portal don't bubble to ancestors — events bubble through the React component tree regardless of DOM position. A click inside a portal modal WILL bubble to the React ancestor that rendered the portal, which can cause unexpected double-handling if you don't stopPropagation.
Many developers think portals are only for modals — any UI that needs to escape a parent's stacking context benefits from portals: tooltips, dropdowns, popovers, toasts, context menus, and date pickers all commonly use them.
Many developers forget accessibility when using portals — rendering a modal in document.body doesn't automatically move keyboard focus or trap it. Without explicit focus management (focus-trap-react), screen reader and keyboard users can tab out of the modal into the background content.
Many developers think portals cause memory leaks if the target is created dynamically — only if you forget the useEffect cleanup. If you use document.getElementById on a pre-existing node (like a #modal-root div in index.html), no cleanup is needed.
Many developers think they need portals for every modal — if the parent component has no overflow:hidden, no transform, no filter, and a high enough z-index, a normal absolutely-positioned component works fine. Only reach for portals when you're actually fighting a stacking context.
Modals and dialogs: every modal library (Radix UI Dialog, Headless UI Dialog, MUI Modal) uses portals internally to render the overlay and dialog into document.body, escaping any parent overflow:hidden or z-index constraints.
Tooltips and popovers: tooltip libraries use portals so a tooltip on a button inside a table cell isn't clipped by the table's overflow:hidden. The tooltip renders in body at the calculated screen position.
Dropdown menus: select boxes and autocomplete dropdowns render their options list via portal so it overflows any parent container and appears above other UI elements regardless of the component's position in the DOM.
Toast notifications: notification systems (React-Toastify, Sonner) render all toasts in a fixed container portalled into body, independent of where the notification was triggered in the component tree.
Drag-and-drop: drag previews (the ghost element that follows your cursor) are portalled into body so they render above everything and aren't clipped by any parent container.
Nested modals: a confirmation modal that appears on top of a main modal — both portalled into body — avoids stacking context issues between the two modal layers.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.