Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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:
role="dialog", aria-modal, aria-label)createPortal(children, domNode) renders children into domNode instead of the parent's DOM node.overflow: hidden, z-index containers) that would clip overlays.| 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 |
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);
}createPortal returns ReactPortal, which is a valid ReactNode.Element or DocumentFragment, not null. Guard with a conditional or state check.document is unavailable during SSR.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.body doesn't exist during server-side rendering. Fix: Guard portal rendering with a useEffect-driven mount check or use "use client".
Multiple portals and z-index wars - Several portals rendering to document.body can 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.
| 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 |
createPortal(children, domNode) renders React children into a DOM node outside the parent's DOM hierarchy.overflow: hidden, z-index containers) that would clip overlays.document.body).e.stopPropagation() if needed.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.body), so it does not inherit parent styles like font, color, or line-height.document.body does not exist during SSR.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);createPortal returns ReactPortal, which is a valid ReactNode.Element or DocumentFragment, not null.null.Element | DocumentFragment.HTMLElement | null and conditionally render.const [container, setContainer] = useState<HTMLElement | null>(null);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;
}.focus() on it in the cleanup function.document.body can stack unpredictably because they share the same stacking context.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥