React portals solve a fundamental problem: DOM hierarchy constraints. A modal rendered inside a deeply nested component inherits its parent’s z-index stacking context and can be clipped by overflow: hidden. Portals let you render children into a different DOM node while preserving the React tree, so context, event handling, and component lifecycle all work as expected.
The key insight is the separation of DOM position from React tree position. A portal’s children are physically rendered elsewhere in the DOM, but events bubble through the React component hierarchy, not the DOM hierarchy. This mental model is essential for understanding all portal patterns.
The createPortal API
The API surface is minimal: ReactDOM.createPortal(children, domNode, key?). The domNode must exist before the render call, so you typically create portal containers in your HTML template or inject them eagerly.
import { createPortal } from "react-dom";
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content" role="dialog" aria-modal="true">
{children}
</div>
</div>,
document.body
);
}
The optional third key parameter controls portal reconciliation. Change the key to force re-mounting the portal subtree — useful when you need to reset internal state.
function Portal({ children, selector, key }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.querySelector(selector) || document.body;
setContainer(el);
}, [selector]);
if (!container) return null;
return createPortal(children, container, key);
}
Portal Target Management
Hardcoding document.body works for simple cases but breaks down in complex layouts. A better approach is named portal targets managed through React context.
const PortalContext = createContext(null);
function PortalHost({ name, children }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.createElement("div");
el.id = `portal-${name}`;
document.body.appendChild(el);
setContainer(el);
return () => document.body.removeChild(el);
}, [name]);
if (!container) return null;
return createPortal(children, container);
}
function usePortal(name) {
return useContext(PortalContext);
}
This enables multi-zone rendering: modals go to #modal-root, tooltips to #tooltip-root, notifications to #notification-root. Each zone can have its own styling and layout, and components select their target declaratively. SSR is handled naturally because the useEffect guard ensures portals are only created on the client.
Nested Portals and Event Propagation
Nested portals occur when a portal-rendered component itself renders a portal — for example, a dropdown menu inside a modal. Event propagation follows the React tree, not the DOM tree, so a click on the dropdown option bubbles through the modal component hierarchy, then to the app root. This is usually the correct behavior, but can be surprising if you expect DOM-level event flow.
function ModalWithDropdown() {
return createPortal(
<Modal>
<Dropdown> {/* This dropdown may also use a portal */}
<DropdownItem onClick={handleAction} />
</Dropdown>
</Modal>,
modalRoot
);
}
For managing z-index in nested portals, a PortalStack component tracks the current depth and applies appropriate layering. Child portals receive the parent’s z-index as context and increment it, preventing overlay conflicts.
Context and State Management
Because portals preserve their React tree position, Context API works naturally. A child rendered via portal can access providers above the portal call site. However, there are edge cases where the portal tree needs to connect to a different provider subtree.
function ConnectedPortal({ children, contextOverrides }) {
return (
<ThemeProvider value={contextOverrides.theme}>
{createPortal(children, document.body)}
</ThemeProvider>
);
}
For cross-portal communication, pass callbacks through context or use a shared Zustand store. The store is accessible from any portal without special wiring, making it the simplest solution for multi-portal coordination.
Animation Integration
Exit animations are tricky with portals because createPortal renders into a DOM node that is removed immediately when the parent unmounts. The solution is to control unmount timing with AnimatePresence from framer-motion.
import { AnimatePresence, motion } from "framer-motion";
function AnimatedModal({ isOpen, onClose, children }) {
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="modal"
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
>
{children}
<button onClick={onClose}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
}
The AnimatePresence component holds the DOM node in place while the exit animation plays, then removes it. Without this wrapper, the portal node would be removed immediately and no exit animation would run.
Accessibility Patterns
Portals require careful accessibility handling. The critical requirements are focus trapping, focus return, and proper ARIA attributes.
function useA11yPortal(triggerRef, isOpen) {
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
} else if (previousFocus.current) {
previousFocus.current.focus();
}
}, [isOpen]);
// Focus trap: keep focus inside the portal while open
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e) {
if (e.key === "Escape") onClose?.();
// Tab cycling logic here
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
}
| Attribute | Purpose |
|---|---|
role="dialog" | Identifies the element as a dialog |
aria-modal="true" | Indicates content outside is inert |
aria-hidden | Applied to background content |
aria-labelledby | References the dialog title |
Set aria-hidden="true" on all sibling elements of the portal container when the modal is open. Some screen readers can still navigate to background content through other means, so combining aria-hidden with inert attribute provides the most robust behavior.
Performance Considerations
Portal content can cause layout thrashing when the portal container is far from the main app root in the DOM tree. Always wrap portal children in useMemo to avoid unnecessary re-renders when the parent component updates but the portal content has not changed.
function App() {
const memoizedModal = useMemo(
() => (
<Modal onClose={handleClose}>
<ExpensiveContent />
</Modal>
),
[handleClose]
);
return (
<>
<MainContent />
{showModal && memoizedModal}
</>
);
}
Changing the key prop of a portal forces React to unmount and remount the entire portal subtree. This is more expensive than inline rendering because of the DOM disconnect and reconnect, so avoid using changing keys on portal containers in hot paths.
Summary
Portals are not just for modals. The same pattern applies to tooltips, dropdowns, notifications, and any UI that needs to break out of a parent’s CSS or DOM constraints. By combining named portal targets, nested portal management, animation integration, and accessibility hooks, you can build a reusable portal abstraction layer that handles the most complex overlay requirements while remaining performant and accessible.
