Portals: Rendering Outside the Parent DOM Hierarchy
Understand how createPortal lets a component render its output into a different part of the DOM tree while still behaving like a normal child in React's component tree — and why that distinction matters for modals, tooltips, and overlays.
Most React components render their JSX into whatever DOM node their parent placed them in — a child of a child of a child, nested exactly where you'd expect from the JSX structure. Portals break that assumption on purpose: createPortal lets a component's rendered output attach to a completely different DOM node, anywhere in the document, while the component itself stays exactly where it is in React's component tree. This is the tool that makes modals, dialogs, tooltips, and dropdown menus work correctly when they need to visually escape a parent's CSS (like overflow: hidden or a low z-index) without escaping React's props, state, and context flow.
The problem portals solve: CSS containment vs component structure
Imagine a Modal component rendered inside a Card component, and the Card has overflow: hidden and a position: relative with a modest z-index. If the Modal renders as a normal DOM child of the Card, it gets visually clipped by that overflow and stacked underneath other page elements, no matter how high you set its own z-index — CSS containment rules trap it inside its DOM ancestor's box, full stop.
The React-tree location and the DOM location are usually the same thing, which is why this problem is easy to overlook until it happens. A portal decouples them: the Modal can still be written and used exactly like <Modal>{children}</Modal> inside the Card in your JSX, but its actual rendered DOM nodes get inserted into a separate container — typically a <div id="portal-root"> sitting as a direct child of <body>, outside every ancestor's overflow and stacking context.
This is the core mental model for portals: they change WHERE something appears in the DOM without changing WHERE it lives in the component tree. Everything else about the component — the props it receives, the state it holds, the context it can read — behaves exactly as if it had rendered in its normal JSX position.
createPortal: the API
createPortal(children, domNode), imported from react-dom, takes the JSX you want to render and a real DOM node to render it into, and returns a special React node you can return from your component just like any other JSX. React handles inserting and cleaning up the actual DOM nodes inside that target container as the component mounts, updates, and unmounts.
The target DOM node is usually not one React itself renders — it's a plain <div> that either already exists in your index.html (like <div id="modal-root"></div> sitting next to your app's root div) or one you create with document.createElement and attach to document.body when the portal component mounts, removing it on unmount. Either approach works; a static div in index.html is simpler for one fixed portal target, while creating a node per-instance is useful when you might need multiple simultaneous portals (e.g., several toasts stacking).
Because createPortal returns a value you use in a render, it's typically the entire return value of a component (return createPortal(<div className="modal">...</div>, modalRoot)) rather than something mixed into a larger JSX tree, though it can appear anywhere a JSX expression is valid, including alongside other normal elements.
Event propagation still follows the React tree, not the DOM tree
This is the single most important and most surprising fact about portals: even though a portaled element's actual DOM node lives outside its parent in the document, an event that bubbles up from inside that portal still reaches React event handlers on its React-tree ancestors — not its DOM-tree ancestors. React's synthetic event system propagates according to the component tree it renders, which is unaffected by createPortal.
Concretely: if a Modal is rendered via a portal into document.body, but is written as a child of a Card component in JSX, then a click inside the Modal will still trigger an onClick handler attached to the Card (or any other React ancestor between Card and Modal), exactly as if the Modal's DOM nodes were nested normally inside the Card's DOM nodes — even though in the actual browser DOM, the Modal's <div> is a sibling of the Card's div, not a descendant.
This matters constantly in practice: a common modal pattern attaches a click handler to an outer wrapper to detect 'click outside the modal to close it.' If that outer wrapper is a React ancestor of the portaled modal content, clicks inside the modal will still bubble to and trigger that ancestor's handler through React's tree — so naive 'click anywhere closes the modal' logic can misfire unless you explicitly stop propagation on the inner content or check the click target.
The takeaway: portals change DOM placement for rendering and CSS purposes only. For every other purpose — event bubbling, context, props, state — a portaled component behaves exactly as though it had never left its place in the React tree.
Accessibility considerations for portaled UI
Because portaled content (modals, dialogs, tooltips) visually detaches from its triggering element, screen reader and keyboard users need extra structure to make sense of it. A modal dialog should carry role="dialog" (or role="alertdialog" for urgent confirmations) and aria-modal="true", be labeled via aria-labelledby pointing at its heading, and should move keyboard focus into itself when it opens and restore focus to the triggering element when it closes.
Focus should be trapped inside an open modal — Tab and Shift+Tab should cycle only through focusable elements inside the dialog, not escape into the page behind it, since the page behind is meant to be inert while the modal is open. This is usually implemented by listening for Tab keydowns and wrapping focus manually, or by using a small focus-trap utility.
Tooltips rendered through portals have a lighter but still real accessibility requirement: the triggering element needs aria-describedby pointing at the tooltip's id so assistive technology announces the relationship, since the DOM proximity that sighted users get for free (a tooltip visually near its trigger) doesn't exist in the accessibility tree unless it's explicitly wired up with ARIA attributes.
createPortal basic shape
modalRoot is a DOM node outside the app's normal render tree — typically a sibling div in index.html. stopPropagation() on modal-content prevents a click inside the modal from bubbling to the overlay's onClose handler.
import { createPortal } from "react-dom";
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const modalRoot = document.getElementById("modal-root")!;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
modalRoot
);
}Code Examples
ConfirmDialog is written and used as a normal child of the outer div in DeleteButton's JSX, but its actual DOM output is inserted into #portal-root, a node outside that div entirely. Expected result: Clicking Yes or Cancel inside the dialog also triggers the outer div's onClick('card clicked') via React-tree bubbling, even though the dialog's DOM node is not physically inside that div — demonstrating that portal event propagation follows the component tree, not the DOM tree.
createPortal(<div role="presentation">...</div>, document.getElementById("portal-root")!)
The tooltip's DOM node is portaled directly to document.body so it can never be clipped by an ancestor's overflow:hidden, while its position is computed each time it appears from the trigger's real on-screen coordinates via getBoundingClientRect. aria-describedby links the trigger span to the portaled tooltip's id so screen readers announce the relationship even though the two elements aren't DOM neighbors.
import { createPortal } from "react-dom";
import { useState } from "react";
function ConfirmDialog({
open,
message,
onConfirm,
onCancel,
}: {
open: boolean;
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
if (!open) return null;
return createPortal(
<div className="overlay" role="presentation">
<div role="alertdialog" aria-modal="true" aria-labelledby="confirm-title">
<h2 id="confirm-title">{message}</h2>
<button onClick={onConfirm}>Yes</button>
<button onClick={onCancel}>Cancel</button>
</div>
</div>,
document.getElementById("portal-root")!
);
}
function DeleteButton() {
const [confirming, setConfirming] = useState(false);
return (
<div onClick={() => console.log("card clicked")}>
<button onClick={() => setConfirming(true)}>Delete item</button>
<ConfirmDialog
open={confirming}
message="Delete this item?"
onConfirm={() => setConfirming(false)}
onCancel={() => setConfirming(false)}
/>
</div>
);
}import { createPortal } from "react-dom";
import { useRef, useState, useLayoutEffect } from "react";
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
const triggerRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (visible && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({ top: rect.bottom + 6, left: rect.left });
}
}, [visible]);
return (
<>
<span
ref={triggerRef}
aria-describedby={visible ? "tooltip-content" : undefined}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{children}
</span>
{visible &&
createPortal(
<div
id="tooltip-content"
role="tooltip"
style={{ position: "fixed", top: coords.top, left: coords.left }}
>
{text}
</div>,
document.body
)}
</>
);
}React tree vs DOM tree for a portaled modal
Portals split rendering location (right) from tree membership (left) — React's event system always follows the left tree.
React component tree: Actual DOM tree:
App <body>
└─ Card ├─ <div id="root">
└─ Modal (portal) │ └─ Card DOM node
└─ dialog content │ (Modal's div is NOT inside here)
└─ <div id="portal-root">
└─ Modal's actual DOM node
Event bubbling: a click inside Modal still
reaches Card's onClick handler, because
React bubbles along the LEFT tree (component
tree), not the RIGHT tree (DOM tree).Important Rules
Always give a portal target a real, existing DOM node before rendering into it — either a static div declared in index.html or one you create and attach in a useEffect/useLayoutEffect before the portal renders.
Never assume clicks inside a portaled child stop at the portal boundary — they bubble through the React ancestor tree exactly like non-portaled children, so use stopPropagation() deliberately where you need to prevent that.
Manage focus explicitly for modal-like portals: move focus in on open, trap it inside while open, and restore it to the trigger element on close.
Clean up any DOM node you created imperatively for a portal (via document.createElement) in a useEffect cleanup function to avoid leaking detached nodes on unmount.
Where portals show up in production UI
Nearly every component library's Modal, Dialog, Drawer, Popover, Tooltip, and Toast/Notification components are built on createPortal internally — Radix UI, Headless UI, Material UI, and Chakra UI all portal these into a dedicated root node so they escape any consuming app's layout constraints. When building a design system or a reusable UI kit, portals are the standard mechanism for guaranteeing that a component always renders on top of everything else regardless of where a consumer happens to place it in their own component tree.
function Overlay({ onClose, children }: { onClose: () => void; children: React.ReactNode }) { return createPortal( <div className="overlay" onClick={onClose}> <div className="content">{children}</div> </div>, document.getElementById("portal-root")!); } function Overlay({ onClose, children }: { onClose: () => void; children: React.ReactNode }) { return createPortal( <div className="overlay" onClick={onClose}> <div className="content" onClick={(e) => e.stopPropagation()}>{children}</div> </div>, document.getElementById("portal-root")!); }
Without stopPropagation on the inner content div, a click anywhere inside the modal's content bubbles up to the overlay's own onClick and triggers onClose, immediately closing the modal the user was trying to interact with.
createPortal(<div>{children}</div>, document.getElementById("modal-root")) const modalRoot = document.getElementById("modal-root"); if (!modalRoot) throw new Error("modal-root element not found in the document"); createPortal(<div>{children}</div>, modalRoot);
Passing a possibly-null result straight to createPortal either crashes with a confusing runtime error or (in non-strict TypeScript setups) silently fails; verifying the target node exists (or using the non-null assertion only when you're certain it's in index.html) surfaces missing-container bugs immediately.
[multiple-choice] A Modal is rendered via createPortal into document.body, but written as a child of a Sidebar component in JSX. A click inside the Modal fires — which React ancestors, if any, see that click bubble through their onClick handlers? A. None — portaled events never bubble past the portal boundary B. Only document.body's own listeners, since that's the real DOM parent C. Sidebar and any other React-tree ancestors, exactly as if the Modal weren't portaled D. Only listeners attached directly to the Modal itself
C. Sidebar and any other React-tree ancestors, exactly as if the Modal weren't portaled — React's synthetic event system bubbles according to the component tree, not the DOM tree, so a portaled child's events still reach its React ancestors' handlers normally.
[multiple-choice] What is the primary CSS-related reason to use a portal for a modal or tooltip? A. Portals make CSS load faster B. Portals let the element escape an ancestor's overflow:hidden or stacking-context limitations C. Portals apply CSS Modules automatically D. Portals remove the need for any CSS at all
B. Portals let the element escape an ancestor's overflow:hidden or stacking-context limitations — Rendering into a DOM node outside a clipping or low-z-index ancestor is exactly the problem portals solve — the component's DOM output is no longer contained by that ancestor's box.
[multiple-choice] Which accessibility practice is specifically important for portaled modal dialogs? A. Setting overflow: hidden on the body B. Trapping keyboard focus inside the open dialog and restoring it to the trigger on close C. Disabling all ARIA attributes to keep markup minimal D. Rendering the dialog without any heading element
B. Trapping keyboard focus inside the open dialog and restoring it to the trigger on close — Because a portaled dialog visually and structurally detaches from its trigger, focus must be managed explicitly — trapped inside while open, and returned to the triggering element when the dialog closes — so keyboard and screen reader users aren't lost in the page.
Build a Toast notification component that portals its message into a #toast-root div fixed to the bottom-right of the viewport, auto-dismisses after 3 seconds via useEffect, and verify that a click on the toast still bubbles to a React ancestor's onClick handler despite being rendered outside that ancestor's DOM subtree.
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) { useEffect(() => { const id = setTimeout(onDismiss, 3000); return () => clearTimeout(id); }, [onDismiss]); return createPortal( <div className="toast">{message}</div>, document.getElementById("toast-root")! ); }
Use setTimeout inside a useEffect (with a cleanup that clears it) to trigger dismissal, and createPortal(children, toastRoot) for the render itself.