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.
What you'll learn
- ✓Why modals belong in portals
- ✓Focus trap and ARIA basics
- ✓Composable overlay APIs
- ✓Stacking and scroll lock issues
- ✓When to reach for a headless library
Prerequisites
- •Familiar with React and the DOM
Modals look simple but are full of traps: focus, scroll, stacking, accessibility, and animation all collide. This post walks through a clean pattern for React overlays that handles the hard parts without painting you into a corner.
What and Why
A modal is an overlay that interrupts the user, demands attention, and blocks interaction with the page underneath. Dialogs, drawers, sheets, and command palettes are all variations on the same idea. The implementation challenges are shared across all of them, so it pays to nail the foundation once.
Rolling your own from scratch is tempting but costly. You will rediscover focus management, escape handling, click-outside detection, scroll lock, body padding for scrollbar jumps, and ARIA roles. Each of those has corner cases. The goal of this post is to give you the mental shape so you can either build it right or pick a headless library that already did.
Mental Model
A modal has three layers stacked above the page: the backdrop, the panel, and the focus boundary. The backdrop dims the page and catches outside clicks. The panel holds your content. The focus boundary keeps tab focus inside the modal until it closes.
They render in a portal, which is a DOM node outside your normal tree. That avoids ancestor overflow: hidden and z-index issues. The component still lives in your React tree for state purposes.
Hands-on Example
Here is a minimal accessible modal using react-dom/createPortal and a small focus trap.
+----------------------------------+
| page content |
| +----------------------------+ |
| | backdrop (dim) | |
| | +----------------------+ | |
| | | panel | | |
| | | focus trapped here | | |
| | +----------------------+ | |
| +----------------------------+ |
+----------------------------------+ import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';
function Modal({ open, onClose, title, children }) {
const panelRef = useRef(null);
useEffect(() => {
if (!open) return;
const prev = document.activeElement;
panelRef.current?.focus();
const onKey = (e) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
prev?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div role="dialog" aria-modal="true" aria-labelledby="modal-title"
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}>
<div ref={panelRef} tabIndex={-1} onClick={(e) => e.stopPropagation()}
style={{ background: 'white', margin: '10vh auto', maxWidth: 480, padding: 24 }}>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>,
document.body,
);
}
This handles escape, outside click, scroll lock, restoring focus, and ARIA. A real focus trap also pins Tab and Shift-Tab inside the panel, which is worth pulling from a library.
Common Pitfalls
Forgetting to lock body scroll is the most common bug. Users scroll the page behind the modal and the world tilts. Setting overflow: hidden works for most cases but causes layout shift on Windows because the scrollbar disappears. Pad the body or use the scrollbar-gutter CSS property.
Stacking modals without a manager leads to focus chaos. If a confirmation opens from inside a modal, you need a stack so escape closes the topmost only.
Animations break unmount. If you animate out, the component must stay mounted during the exit, which means open cannot directly control rendering. Use a state machine or a library like Framer Motion’s AnimatePresence.
Best Practices
Reach for a headless library like Radix, Headless UI, or React Aria for production work. They solve focus traps, ARIA, and stacking correctly and let you bring your own styles.
Keep modal state as close to the trigger as possible. A useModal hook that returns { open, setOpen } is enough for most pages. Global modal stores tempt early but add complexity.
Always provide a way to close other than the X button: escape, click outside, and a cancel action. Honor the user’s reduced-motion preference for animations.
Wrap-up
Modals are deceptively detailed. Get portals, focus, scroll lock, and ARIA right and the rest is just styling. When in doubt, stand on a headless library, but understand the layers underneath so you can debug them.
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.
- 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.
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.