React Portals and Modal Patterns
Build accessible modals, tooltips, and dropdowns with React Portals. Learn focus trapping, keyboard handling, and reusable modal patterns.
What you'll learn
- ✓How React Portals render outside the DOM hierarchy
- ✓Building accessible modals with focus trapping
- ✓Keyboard handling and scroll locking
- ✓Reusable modal patterns with compound components
Prerequisites
- •React components and hooks
- •Basic DOM knowledge (event bubbling, z-index)
React Portals let you render children into a DOM node outside the parent component’s hierarchy. This solves a fundamental problem: overlays like modals, tooltips, and dropdowns often get clipped by ancestor elements with overflow: hidden or wrong z-index stacking contexts.
The Problem Portals Solve
Imagine a modal inside a card component:
function Card() {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ overflow: 'hidden', position: 'relative' }}>
<h3>Card Title</h3>
<button onClick={() => setShowModal(true)}>Open Details</button>
{showModal && (
<div className="modal-overlay">
{/* This modal gets clipped by the parent's overflow: hidden */}
<div className="modal">Modal content</div>
</div>
)}
</div>
);
}
The modal renders inside the card’s DOM, so overflow: hidden clips it. Portals fix this by rendering the modal at the end of document.body.
Creating a Portal
Use createPortal from react-dom:
import { createPortal } from 'react-dom';
function Portal({ children }: { children: React.ReactNode }) {
return createPortal(children, document.body);
}
function Card() {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ overflow: 'hidden' }}>
<h3>Card Title</h3>
<button onClick={() => setShowModal(true)}>Open Details</button>
{showModal && (
<Portal>
<div className="modal-overlay">
<div className="modal">
<p>This renders at document.body, outside the card</p>
<button onClick={() => setShowModal(false)}>Close</button>
</div>
</div>
</Portal>
)}
</div>
);
}
Even though the modal renders in document.body in the DOM, it still behaves as a child of Card in React’s tree. Events bubble through the React tree, not the DOM tree. Clicking inside the portal still triggers React event handlers on parent components.
Building an Accessible Modal
A proper modal needs more than just a portal. It needs focus trapping, keyboard handling, scroll locking, and ARIA attributes:
import { createPortal } from 'react-dom';
import { useEffect, useRef, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Lock body scroll
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
previousActiveElement.current?.focus();
};
}
}, [isOpen]);
// Focus trap
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
},
[onClose]
);
// Auto-focus modal on open
useEffect(() => {
if (isOpen && modalRef.current) {
const firstFocusable = modalRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="modal-close"
>
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
This modal handles:
- Focus trapping: Tab and Shift+Tab cycle through focusable elements inside the modal.
- Escape key: Pressing Escape closes the modal.
- Scroll locking: Body scroll is disabled while the modal is open.
- Focus restoration: When the modal closes, focus returns to the element that opened it.
- ARIA attributes:
role="dialog",aria-modal="true", andaria-labelledbymake it accessible to screen readers. - Overlay click: Clicking outside the modal closes it.
Using the Modal
function ProductPage() {
const [showConfirm, setShowConfirm] = useState(false);
return (
<div>
<h1>Product Details</h1>
<button onClick={() => setShowConfirm(true)}>Delete Product</button>
<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Confirm Deletion"
>
<p>Are you sure you want to delete this product? This action cannot be undone.</p>
<div className="modal-actions">
<button onClick={() => setShowConfirm(false)}>Cancel</button>
<button
onClick={() => {
deleteProduct();
setShowConfirm(false);
}}
className="danger"
>
Delete
</button>
</div>
</Modal>
</div>
);
}
Compound Component Modal Pattern
For more flexibility, use a compound component pattern that lets consumers compose the modal structure:
import { createContext, useContext } from 'react';
interface ModalContextValue {
onClose: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
function useModalContext() {
const context = useContext(ModalContext);
if (!context) throw new Error('Modal components must be used within Modal');
return context;
}
function ModalRoot({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!isOpen) return null;
return createPortal(
<ModalContext.Provider value={{ onClose }}>
<div className="modal-overlay" onClick={onClose} role="presentation">
<div
className="modal"
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>
</ModalContext.Provider>,
document.body
);
}
function ModalHeader({ children }: { children: React.ReactNode }) {
const { onClose } = useModalContext();
return (
<header className="modal-header">
{children}
<button onClick={onClose} aria-label="Close modal">×</button>
</header>
);
}
function ModalBody({ children }: { children: React.ReactNode }) {
return <div className="modal-body">{children}</div>;
}
function ModalFooter({ children }: { children: React.ReactNode }) {
return <footer className="modal-footer">{children}</footer>;
}
// Export as a namespace
const Modal = Object.assign(ModalRoot, {
Header: ModalHeader,
Body: ModalBody,
Footer: ModalFooter,
});
export { Modal };
Usage with compound components:
function SettingsPage() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Edit Profile</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Header>
<h2>Edit Profile</h2>
</Modal.Header>
<Modal.Body>
<form id="profile-form">
<label htmlFor="display-name">Display Name</label>
<input id="display-name" name="displayName" />
<label htmlFor="bio">Bio</label>
<textarea id="bio" name="bio" />
</form>
</Modal.Body>
<Modal.Footer>
<button onClick={() => setIsOpen(false)}>Cancel</button>
<button type="submit" form="profile-form">Save Changes</button>
</Modal.Footer>
</Modal>
</>
);
}
Portals for Tooltips
Portals work great for tooltips that need to escape container boundaries:
function Tooltip({ children, text, position = 'top' }: {
children: React.ReactNode;
text: string;
position?: 'top' | 'bottom' | 'left' | 'right';
}) {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
function showTooltip() {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const positions = {
top: { top: rect.top - 8, left: rect.left + rect.width / 2 },
bottom: { top: rect.bottom + 8, left: rect.left + rect.width / 2 },
left: { top: rect.top + rect.height / 2, left: rect.left - 8 },
right: { top: rect.top + rect.height / 2, left: rect.right + 8 },
};
setCoords(positions[position]);
setIsVisible(true);
}
return (
<>
<span
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={() => setIsVisible(false)}
onFocus={showTooltip}
onBlur={() => setIsVisible(false)}
tabIndex={0}
>
{children}
</span>
{isVisible &&
createPortal(
<div
className={`tooltip tooltip-${position}`}
style={{ position: 'fixed', top: coords.top, left: coords.left }}
role="tooltip"
>
{text}
</div>,
document.body
)}
</>
);
}
Portal Event Bubbling
A key feature of portals is that events bubble through the React tree, not the DOM tree:
function Parent() {
return (
<div onClick={() => console.log('Parent clicked')}>
<Portal>
<button onClick={() => console.log('Button clicked')}>
Click me
</button>
</Portal>
</div>
);
}
// Clicking the button logs both "Button clicked" and "Parent clicked"
This is important for context providers. A modal rendered through a portal still has access to context from its React parent, even though it lives at document.body in the DOM.
Common Pitfalls
Forgetting to clean up scroll lock: Always restore document.body.style.overflow in the cleanup function of your effect. If the component unmounts without cleanup, the page stays locked.
Multiple modals: If you open a modal from within a modal, the second modal’s close handler might restore scroll too early. Use a counter or stack to track how many modals are open.
Server-side rendering: document.body does not exist on the server. Guard portal rendering with a mounted check:
function SafePortal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
}
Wrapping Up
React Portals solve the DOM hierarchy problem for overlays. Combine them with focus trapping, keyboard handling, scroll locking, and proper ARIA attributes to build modals that are both functional and accessible. The compound component pattern gives consumers flexibility to compose modal content however they need. Use portals whenever you need to render something that should visually escape its parent container while staying connected to it in the React tree.
Related articles
- React React Portals and Modals: A Practical Tutorial
Learn how React portals work, when to use them, and how to build accessible modals that escape parent overflow and z-index traps.
- React React Modal and Overlay Patterns
Build accessible, composable modals and overlays in React using portals, focus traps, and headless primitives — with pitfalls around scroll lock, stacking, and a11y.
- Tailwind Tailwind with Headless UI Tutorial
Use Headless UI's accessible React components alongside Tailwind utilities to build menus, dialogs, comboboxes, and toggles that match your design system exactly.
- Tailwind Tailwind with Radix UI Tutorial
Combine Radix UI's accessible unstyled primitives with Tailwind utilities to build production-grade dialogs, menus, and tooltips that look exactly the way you want.