Portals
A portal lets you render a component’s children into a DOM node that lives outside the parent component’s DOM hierarchy, while keeping that component fully inside the React tree. This is the standard solution for UI that must visually escape its container — modals, tooltips, dropdowns, and toasts — where overflow, clipping, or z-index stacking from an ancestor would otherwise break the layout. The mental model is the key thing to grasp: portals change where the markup appears in the DOM, not where the component sits in the React tree.
Why portals exist
React normally renders a component’s output as a direct DOM child of its parent. That coupling is usually what you want, but it fails for floating UI. If a modal is nested deep inside a card that has overflow: hidden or a transform, the modal gets clipped or trapped in a stacking context it can’t escape. You could lift the modal to the top of your component tree, but then you lose colocation with the logic that controls it.
Portals solve this by letting the component stay where it belongs in React — receiving props, context, and state from its parent — while its DOM output is teleported to a node such as document.body.
Creating a portal
createPortal comes from react-dom, not react. It takes the React children to render and a real DOM node to render them into.
import { createPortal } from "react-dom";
function Tooltip({ children }) {
return createPortal(
<div className="tooltip">{children}</div>,
document.body,
);
}
The first argument is anything renderable (JSX, a string, a fragment). The second is the target DOM container. You call createPortal inside your component’s render and return its result like any other JSX.
| Argument | Type | Description |
|---|---|---|
children | ReactNode | The content to render into the target node |
domNode | Element | DocumentFragment | An existing DOM node to render the children into |
key (optional) | string | A unique key, useful when rendering a list of portals |
Events bubble through the React tree, not the DOM tree
This is the most surprising and most useful behavior. Even though the portal’s DOM lives under document.body, events still propagate to the portal’s React ancestors. A click inside a portaled modal will trigger onClick handlers on the React parent that rendered the modal — regardless of where the modal’s DOM node sits.
function Parent() {
return (
<div onClick={() => console.log("Parent saw the click")}>
<Tooltip>Hover content rendered into document.body</Tooltip>
</div>
);
}
Output:
Parent saw the click
This means context, theming, and event delegation all keep working as if the portal were a normal child. You rarely need to wire anything up manually.
Because events bubble through React, a “click outside to close” handler attached to an ancestor can fire on clicks inside the portal. Detect outside clicks against the portal’s own DOM node (e.g., with a ref), not against the React parent.
A worked modal example
A production-quality modal needs more than createPortal: it should trap focus, restore focus on close, close on Escape, and lock body scroll. Here is a complete, runnable component.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export function Modal({ isOpen, onClose, title, children }) {
const dialogRef = useRef(null);
const previouslyFocused = useRef(null);
useEffect(() => {
if (!isOpen) return;
previouslyFocused.current = document.activeElement;
dialogRef.current?.focus();
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
previouslyFocused.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
className="modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
ref={dialogRef}
onClick={(event) => event.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body,
);
}
Using it from a parent is unremarkable — that’s the point:
import { useState } from "react";
import { Modal } from "./Modal";
export function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open modal</button>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
title="Confirm action"
>
<p>Are you sure you want to continue?</p>
</Modal>
</>
);
}
The overlay’s onClick closes the modal, while the dialog stops propagation so clicks inside don’t dismiss it. Focus moves into the dialog on open and returns to the trigger button on close.
Accessibility and focus
Portals fix layout problems but introduce accessibility responsibilities that the DOM no longer handles for you:
- Set
role="dialog"andaria-modal="true"so assistive tech announces a modal context. - Provide an accessible name with
aria-labelledby(oraria-label). - Move focus into the dialog on open and restore it to the trigger on close — shown above with
previouslyFocused. - Trap focus inside the dialog so
Tabcannot reach background content. For full focus trapping in production, reach for a vetted library likefocus-trap-reactrather than hand-rolling edge cases.
Source order still matters for screen readers. Rendering into
document.bodyplaces the dialog at the end of the DOM. Usearia-modal="true"and focus management so the rest of the page is effectively inert while the modal is open.
Best Practices
- Keep portal targets stable: render into a long-lived node like
document.bodyor a dedicated<div id="portal-root">rather than creating and destroying containers on every render. - Render
nullwhen the overlay is closed instead of toggling CSS visibility, so the portal’s DOM and event listeners are fully removed. - Always pair modals with focus management and an
Escapehandler —createPortaldoes none of this for you. - Use
event.stopPropagation()on the dialog and detect outside clicks via the portal’s ref, remembering that events bubble through React ancestors. - Lock body scroll while a full-screen overlay is open, and restore it in the effect cleanup.
- Prefer a battle-tested focus-trap utility over custom
Tabhandling for anything user-facing.