Portals for Overlays Done Right
Escape overflow and z-index traps with portals, while keeping focus, events and context intact.
A portal renders children into a DOM node outside the parent's hierarchy while keeping them in the React tree. That split is exactly what overlays need: modals, tooltips, popovers and toasts must escape a parent's overflow: hidden or stacking context, yet still receive React context and bubble events to their logical parent.
Why the React tree still matters
Even though the modal's DOM lands at document.body, in React it is still your child. So useContext works, event bubbling reaches your handlers, and state flows down normally. You get DOM freedom without losing React's wiring.
function Portal({ children }) {
return createPortal(children, document.body);
}What a portal does NOT give you for free
Portals solve placement. A production overlay still needs the accessibility work portals do not do:
- Focus management — move focus into the dialog on open, trap it while open, and restore it to the trigger on close.
- Escape + backdrop — close on
Escapeand on backdrop click, without closing when clicking inside. - ARIA —
role="dialog",aria-modal="true", and a label so screen readers announce it.
The native <dialog> element handles much of this, and libraries like Radix or React Aria handle all of it. Understand the pattern, then lean on a tested implementation for anything shipping to users.
Example
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ title, onClose, children }) {
const dialogRef = useRef(null);
useEffect(() => {
const previouslyFocused = document.activeElement;
dialogRef.current?.focus();
function onKey(e) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
// Restore focus to whatever opened the modal.
previouslyFocused?.focus?.();
};
}, [onClose]);
return createPortal(
<div onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
ref={dialogRef}
onClick={(e) => e.stopPropagation()} // don't close on inside click
>
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}When to use it
- A confirmation modal that must visually overlay the entire viewport escapes a parent container with overflow:hidden by rendering into document.body via a portal.
- A floating tooltip rendered inside a portal avoids z-index stacking context issues caused by positioned ancestors while still receiving React context from its trigger.
- A toast notification system mounts each toast into a dedicated #toast-root element at the bottom of the HTML body via portals, keeping toast state managed inside React.
More examples
Basic portal with createPortal
createPortal renders the modal's DOM into document.body, escaping any overflow or stacking context from the parent, while keeping React event bubbling intact.
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-panel"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}Portal with focus management
The effect saves the previously focused element on open, moves focus into the dialog, and restores it on close to satisfy keyboard and screen-reader accessibility requirements.
function Dialog({ isOpen, onClose, children }) {
const dialogRef = React.useRef(null);
const previousFocus = React.useRef(null);
React.useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
dialogRef.current?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div ref={dialogRef} role="dialog" tabIndex={-1} aria-modal="true">
{children}
<button onClick={onClose}>Close</button>
</div>,
document.body
);
}Reusable portal hook
usePortal lazily creates a dedicated host element on first call, giving tooltips their own container separate from modal portals.
function usePortal(hostId = 'portal-root') {
const [container] = React.useState(() => {
let el = document.getElementById(hostId);
if (!el) {
el = document.createElement('div');
el.id = hostId;
document.body.appendChild(el);
}
return el;
});
return container;
}
function Tooltip({ label, children }) {
const container = usePortal('tooltip-root');
return createPortal(
<div className="tooltip">{label}</div>,
container
);
}
Discussion