React Portals — Render children into a DOM node outside the parent component's DOM hierarchy
Recipe
import { createPortal } from "react-dom";
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md w-full">
{children}
</div>
</div>,
document.body
);
}
// Usage
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<h2>Confirm action</h2>
<p>Are you sure?</p>
</Modal>When to reach for this: When a component needs to render visually outside its parent's DOM tree (modals, dropdowns, tooltips, toasts) but still participate in the React event and context tree.
Working Example
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
// --- Accessible modal with focus trap ---
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Trap focus and handle escape
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 animate-fade-in"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative bg-white rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4 animate-scale-in"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="p-1 rounded hover:bg-gray-100"
>
X
</button>
</div>
{children}
</div>
</div>,
document.body
);
}
// --- Tooltip using portal ---
function Tooltip({
children,
content,
}: {
children: ReactNode;
content: string;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
const show = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.top - 8 + window.scrollY,
left: rect.left + rect.width / 2 + window.scrollX,
});
}
setVisible(true);
}, []);
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
onFocus={show}
onBlur={() => setVisible(false)}
>
{children}
</span>
{visible &&
createPortal(
<div
role="tooltip"
className="absolute -translate-x-1/2 -translate-y-full px-2 py-1 bg-gray-900 text-white text-sm rounded pointer-events-none"
style={{ top: coords.top, left: coords.left }}
>
{content}
</div>,
document.body
)}
</>
);
}
// --- Usage ---
function SettingsPage() {
const [showConfirm, setShowConfirm] = useState(false);
return (
<div className="p-8">
<h1>Settings</h1>
<Tooltip content="This will delete all your data">
<button
onClick={() => setShowConfirm(true)}
className="text-red-600 underline"
>
Delete Account
</button>
</Tooltip>
<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Delete Account"
>
<p className="mb-4">This action cannot be undone. Are you sure?</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancel
</button>
<button className="btn-danger">Delete</button>
</div>
</Modal>
</div>
);
}What this demonstrates:
- Modal portal with focus trap, escape key handling, and scroll lock
- Tooltip portal positioned relative to its trigger element
- ARIA attributes for accessibility (
role="dialog",aria-modal,aria-label) - Previous focus restoration when the modal closes
Deep Dive
How It Works
createPortal(children, domNode)renderschildrenintodomNodeinstead of the parent's DOM node.- Despite the DOM placement, portals remain part of the React tree. Context, events, and state flow as if the portal is still a child of its React parent.
- Event bubbling works through the React tree, not the DOM tree. A click inside a portal will bubble to the React parent, not the DOM parent.
- Portals are commonly used to escape CSS stacking contexts (
overflow: hidden,z-indexcontainers) that would clip overlays.
Parameters & Return Values
| Parameter | Type | Purpose |
|---|---|---|
children | ReactNode | The React elements to render in the portal |
domNode | Element or DocumentFragment | The DOM node to render into |
key (optional) | string | Unique key for the portal |
| Return value | ReactPortal | A special React element representing the portal |
Variations
Dynamic portal container — create and clean up a dedicated DOM node:
function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let element = document.getElementById(id);
if (!element) {
element = document.createElement("div");
element.id = id;
document.body.appendChild(element);
}
setContainer(element);
return () => {
if (element && element.childNodes.length === 0) {
element.remove();
}
};
}, [id]);
return container;
}
function ToastContainer({ children }: { children: ReactNode }) {
const container = usePortalContainer("toast-root");
if (!container) return null;
return createPortal(children, container);
}Server-side compatible portal — guard against missing document:
function ClientPortal({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
}TypeScript Notes
createPortalreturnsReactPortal, which is a validReactNode.- The second argument must be an
ElementorDocumentFragment, notnull. Guard with a conditional or state check. - When using portals in Next.js, always guard with a client-side mount check since
documentis unavailable during SSR.
Gotchas
-
CSS inheritance breaks — Styles inherited from parent DOM elements (font, color) don't apply to portaled content since it lives elsewhere in the DOM. Fix: Ensure portaled content defines its own base styles or use a CSS reset wrapper.
-
SSR errors —
document.bodydoesn't exist during server-side rendering. Fix: Guard portal rendering with auseEffect-driven mount check or use"use client". -
Multiple portals and z-index wars — Several portals rendering to
document.bodycan stack unpredictably. Fix: Use a dedicated portal root with a stacking context, or manage z-index through a portal manager. -
Event bubbling confusion — Events bubble through the React tree, not the DOM tree. A click inside a modal portal bubbles to the React parent, which may trigger unintended handlers. Fix: Use
e.stopPropagation()in the portal content if the parent has competing click handlers. -
Focus management — Opening a portal without moving focus leaves keyboard users stranded. Fix: Move focus into the portal on open and restore it on close, as shown in the working example.
Alternatives
| Approach | Trade-off |
|---|---|
createPortal | Full control; manual focus and accessibility management |
HTML <dialog> element | Native modal with built-in focus trap; limited styling |
| Radix Dialog / Headless UI | Full accessibility built-in; extra dependency |
CSS position: fixed without portal | Simpler; breaks inside overflow: hidden or transform parents |
Popover API (popover attribute) | Native browser API; limited browser support and React integration |
FAQs
What does createPortal do and why is it needed?
createPortal(children, domNode)renders React children into a DOM node outside the parent's DOM hierarchy.- It is needed to escape CSS stacking contexts (
overflow: hidden,z-indexcontainers) that would clip overlays. - Common use cases include modals, tooltips, dropdowns, and toast notifications.
Do portals stay part of the React component tree even though they render elsewhere in the DOM?
- Yes. Despite the DOM placement, portals remain part of the React tree.
- Context, events, and state flow as if the portal is still a child of its React parent.
- This is a key difference from manually appending DOM elements.
How does event bubbling work with portals?
- Events bubble through the React tree, not the DOM tree.
- A click inside a modal portal bubbles to the React parent, not the DOM parent (
document.body). - This can trigger unintended handlers on the React parent. Use
e.stopPropagation()if needed.
How do you implement focus trapping in a modal portal?
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}- Query all focusable elements inside the dialog.
- On Tab, wrap focus from last to first (and vice versa with Shift+Tab).
Gotcha: Why does portaled content lose CSS inherited styles?
- Portaled content lives in a different DOM location (e.g.,
document.body), so it does not inherit parent styles like font, color, or line-height. - Fix: ensure portaled content defines its own base styles or uses a CSS reset wrapper.
Gotcha: Why does createPortal crash during server-side rendering?
document.bodydoes not exist during SSR.- Fix: guard portal rendering with a
useEffect-driven mount check or use"use client"in Next.js.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);What is the return type of createPortal in TypeScript?
createPortalreturnsReactPortal, which is a validReactNode.- The second argument must be an
ElementorDocumentFragment, notnull. - Guard with a conditional or state check to avoid passing
null.
How do you type the domNode parameter of createPortal in TypeScript?
- The parameter type is
Element | DocumentFragment. - When using a ref or state to hold the container, type it as
HTMLElement | nulland conditionally render. - Example:
const [container, setContainer] = useState<HTMLElement | null>(null);
How do you create a dynamic portal container that cleans up after itself?
function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("div");
el.id = id;
document.body.appendChild(el);
}
setContainer(el);
return () => { if (el && el.childNodes.length === 0) el.remove(); };
}, [id]);
return container;
}- Create the container on mount and remove it on unmount if empty.
- This avoids accumulating stale DOM nodes.
Why should you restore focus when a modal portal closes?
- Without focus restoration, keyboard users lose their place in the page after the modal closes.
- Save the previously focused element before opening the modal and call
.focus()on it in the cleanup function. - This is an accessibility requirement for dialogs.
How do multiple portals interact with z-index stacking?
- Several portals rendering to
document.bodycan stack unpredictably because they share the same stacking context. - Fix: use a dedicated portal root with a managed stacking context, or assign z-index values through a portal manager.
- Alternatively, render portals in order of priority so later portals naturally stack above earlier ones.
Related
- Composition — Portals use children composition for content injection
- Error Boundaries — Wrap portals in error boundaries for safety
- Compound Components — Modal dialogs often use compound component APIs