Yes, you can build a React modal component that works reliably by controlling it with state, rendering it in a portal, and handling focus, escape, and click-outside behavior. This article walks through a complete example, explains the core concepts, and covers common pitfalls so your dialog is accessible and robust.
Why Modals Are Tricky in React
Modals are a common UI pattern, but they’re easy to get wrong. The main challenges are:
- Rendering location: If you render a modal inside a deeply nested component, its stacking context and overflow properties can clip it. Using a React portal to render it into
document.bodyavoids this. - Accessibility: Users on screen readers need the modal to be announced, focus to be trapped inside, and the rest of the page to be inert. Ignoring this makes the dialog unusable for some people.
- State management: The modal’s open/closed state lives in the parent component. You need a clean way to open, close, and pass data.
Core Concepts: Portal, State, and Events
A modal component typically relies on three React features:
- State: A boolean like
isOpenin the parent controls whether the modal renders. - Portal:
ReactDOM.createPortal(children, document.body)renders the modal outside the parent DOM tree, avoiding CSS clipping. - Event handlers: You attach
onClickto a backdrop andonKeyDownfor Escape to close the modal.
Here’s a minimal but functional example:
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}
This works for simple cases, but production dialogs need more: focus management, scroll locking, and proper ARIA attributes.
Building a Complete Example
Let’s build a modal that meets common requirements. We’ll use React hooks for state and effects.
- Setup state: In the parent,
const [isOpen, setIsOpen] = useState(false). - Create the modal component that accepts
isOpen,onClose, andchildren. - Use a portal to render into
document.body. - Add an effect to listen for the Escape key and to lock body scroll.
- Focus the first focusable element when the modal opens, and restore focus to the trigger button when it closes.
Here’s a more complete implementation:
import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const previouslyFocused = document.activeElement;
modalRef.current?.focus();
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
previouslyFocused?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
ref={modalRef}
onClick={e => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}
This handles the basics. For full accessibility, you’d also implement focus trapping (keeping Tab within the modal) and an aria-describedby for the description.
Focus Trapping and Accessibility
Focus trapping ensures that keyboard users can’t Tab out of the modal into the page behind. Without it, they might lose context. A simple approach is to query all focusable elements inside the modal and cycle focus on Tab and Shift+Tab.
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function handleTab(e) {
if (e.key !== 'Tab') return;
const focusables = modalRef.current.querySelectorAll(focusableSelector);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
Add this to the keydown listener. Also, set aria-modal="true" and role="dialog" so screen readers treat it as a dialog. Use aria-labelledby to point to the title.
Common Mistakes and How to Fix Them
- Not using a portal: The modal gets clipped by an ancestor with
overflow: hidden. Fix: render via portal. - Click-outside closing when clicking inside: The backdrop’s onClick fires even when you click the content. Fix: call
e.stopPropagation()on the content. - Escape key not working: You forgot to attach the listener or it’s removed too early. Fix: add in a useEffect with cleanup.
- Body scroll not locked: The page scrolls behind the modal. Fix: set
document.body.style.overflow = 'hidden'on open and restore on close. - Focus not trapped: Users can Tab out. Fix: implement the keydown handler above.
When to Use a Library vs. Build Your Own
If your app needs a simple confirm dialog, building your own is fine. But if you need complex features like nested modals, animations, or drag-and-drop, a library like React Modal or Headless UI can save time. Libraries are battle-tested for accessibility. However, they add bundle size and a learning curve. For a single dialog, a custom component is often lighter and more educational.
Frequently Asked Questions
Why does my modal appear behind other elements?
This is usually a stacking context issue. If an ancestor has a transform, filter, or z-index, it creates a new stacking context, and the modal can’t escape it. Using a portal to document.body avoids this, but ensure the body has no conflicting z-index.
How do I prevent the modal from closing when clicking inside?
Add onClick={(e) => e.stopPropagation()} to the modal content div. This stops the event from bubbling to the overlay, which has the close handler.
Is it safe to use document.body.style.overflow?
Yes, but remember to restore the original value when the modal closes. If you have multiple modals open, you need a counter or a utility that tracks the number of open modals to avoid prematurely unlocking scroll.
How do I animate the modal with CSS?
You can add CSS transitions to the overlay and content. However, because the component returns null when closed, you need to keep it mounted during the exit animation. Use a state like isClosing to delay unmounting, or use a library like framer-motion.
What is the difference between role="dialog" and role="alertdialog"?
Use role="dialog" for general dialogs. Use role="alertdialog" only when the dialog requires an immediate response, like a confirmation that an action will be destructive. Screen readers may announce alertdialogs differently.
Can I use a modal inside a form?
Yes, but be careful with nested forms. HTML doesn’t allow nested <form> elements. If your modal contains a form, it should be outside the main form or use the form attribute to associate it.