React Modal Tutorial: Installation, Setup & Accessible Dialogs
React Modal Tutorial: Installation, Setup & Accessible Dialogs
Why react-modal Is Still the Go-To React Modal Library in 2025
Modals sound simple until you actually build one from scratch. Then you discover
the joys of focus trapping, scroll locking, keyboard dismissal, ARIA attributes,
portal rendering, and the inevitable edge-case where your CSS z-index
has a personal vendetta against you. The
react-modal
package solves this entire category of problems with a single, well-maintained
React component
that ships with zero opinion about your styling choices — which is either a
feature or a bug, depending on how much you like writing CSS.
The library has been around long enough to handle every weird browser quirk
you can throw at it, yet its API has stayed refreshingly small. There is one
component (<Modal>), a handful of props, and one required
setup call. That is the entire public surface. Compare that with some
UI-framework-bundled dialog components that need you to read a 40-page docs
site before you can show a simple confirmation prompt, and you start to
appreciate the restraint.
As of 2025, react-modal sits at roughly 4 million weekly
downloads on npm. It is not the flashiest
React modal library
on the shelf — it does not animate by default, it does not come with a design
system — but it is the one that will never surprise you in production. For
teams that need a solid, accessible foundation and want full control over
visuals, it remains the pragmatic choice.
react-modal Installation and Initial Setup
Getting react-modal
into a project is one of those rare moments in frontend development that goes
exactly as expected. Open a terminal, run one command, and you are done with
the installation phase forever.
# npm
npm install react-modal
# yarn
yarn add react-modal
# pnpm
pnpm add react-modal
TypeScript users get first-class type definitions via
@types/react-modal, which you install as a dev dependency.
The types cover every prop, callback signature, and style interface, so your
IDE’s autocomplete will be genuinely useful rather than decoratively present.
npm install --save-dev @types/react-modal
After installation, there is one critical step that trips up almost every
first-time user: you must tell react-modal which element is your
application root. The library needs this information to apply the
aria-hidden="true" attribute to the rest of the page when a
modal is open — that is how screen readers know to ignore background content.
Place this call once, at the top level of your app, and never think about it
again.
// index.tsx or main.tsx — run this once, globally
import Modal from 'react-modal';
Modal.setAppElement('#root');
setAppElement() and you will get a console warningin development and broken screen-reader behavior in production.
It is not optional — it is the one non-negotiable line in the entire
react-modal setup process.
Your First react-modal Example: A Minimal Dialog Component
The mental model for react-modal is deliberately aligned with how React itself
works: you control visibility through state, and you pass that state to the
isOpen prop. The modal renders through a
React portal
directly into document.body, which means stacking-context issues
caused by parent overflow: hidden or z-index simply
do not apply. The overlay always ends up exactly where you expect it.
import { useState } from 'react';
import Modal from 'react-modal';
// Required: bind modal to your app element
Modal.setAppElement('#root');
export default function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>
Open Modal
</button>
<Modal
isOpen={isOpen}
onRequestClose={() => setIsOpen(false)}
contentLabel="Example Dialog"
>
<h2>Hello from react-modal</h2>
<p>Press Escape or click the button below to close.</p>
<button onClick={() => setIsOpen(false)}>
Close
</button>
</Modal>
</div>
);
}
Three props are doing the heavy lifting here.
isOpen is the single source of truth for modal visibility.
onRequestClose fires on both Escape key presses and overlay
clicks — note that it does not close the modal automatically; you close it
by updating your state, which keeps you firmly in control.
contentLabel is an ARIA label read by screen readers to
announce the dialog’s purpose, and it is mandatory for accessibility compliance.
The result above is functional but visually invisible — no styles means
a white box on a transparent background. That is intentional. The library
authors deliberately ship no CSS so that every team can apply whatever design
system they are working with. In the next section, you will see how to make
it look like an actual modal rather than a CSS crime scene.
react-modal Styling: Inline Styles, CSS Classes, and Tailwind
React-modal exposes two styling surfaces: the content (the
dialog box itself) and the overlay (the darkened backdrop).
You can target both through three independent mechanisms — the style
prop, the className and overlayClassName props, or
a combination of both. The library is not going to fight you regardless of
which approach you choose.
Option A — Inline Style Object
const modalStyles = {
content: {
width: '480px',
margin: 'auto',
padding: '2rem',
borderRadius: '12px',
border: 'none',
boxShadow: '0 25px 50px rgba(0,0,0,.15)',
inset: 'unset', // override the default positioning
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: 'fixed',
},
overlay: {
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(3px)',
zIndex: '1000',
},
};
<Modal isOpen={isOpen} style={modalStyles} onRequestClose={close}>
...
</Modal>
The inline approach is fast for prototyping and keeps everything collocated,
but it does not support pseudo-classes, media queries, or animations — and
a 20-line style object inside JSX starts feeling uncomfortable quickly.
For anything beyond “I just need this to look acceptable in a demo,” switch
to class-based styling.
Option B — CSS / CSS Modules / Tailwind
<Modal
isOpen={isOpen}
className="modal-content"
overlayClassName="modal-overlay"
onRequestClose={close}
>
...
</Modal>
/* modal.css */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 2rem;
max-width: 480px;
width: 90%;
box-shadow: 0 25px 50px rgba(0,0,0,.15);
position: relative;
}
When you use className, react-modal completely replaces its default
class (which carries some default positioning via .ReactModal__Content).
This means you own 100% of the layout — no fighting specificity wars, no
!important theatrics. If you are using Tailwind CSS, you can pass
a template string of utility classes directly to className, though
a dedicated wrapper div inside the modal often gives you cleaner markup.
For animations, react-modal offers
closeTimeoutMS — a number of milliseconds to delay the actual
DOM removal after close — which gives your CSS transition time to complete.
Pair it with .ReactModal__Content--after-open and
.ReactModal__Content--before-close class hooks that the library
applies automatically, and you have everything you need for a smooth fade
or slide animation without reaching for a third-party animation library.
React Accessible Modal: What react-modal Gets Right
Accessibility in dialog components is one of the more demanding areas of
frontend engineering, and it is also one of the most frequently skipped.
React-modal earns its place in production codebases partly because it ships
with WAI-ARIA dialog pattern
compliance by default, which means you do not have to manually implement
focus trapping, role="dialog", or keyboard event handling.
Those behaviors are baked in.
Specifically, when a modal opens, react-modal:
- Moves keyboard focus into the dialog automatically
- Traps focus so Tab and Shift+Tab cycle only through focusable elements inside the modal
- Adds
role="dialog"andaria-modal="true"to the content element - Applies
aria-hidden="true"to the app root (viasetAppElement), hiding background content from screen readers - Restores focus to the trigger element when the modal closes
The contentLabel prop maps to aria-label on the
dialog element — it is what a screen reader announces when the modal opens.
If your modal has a visible heading, you can use aria-labelledby
instead, pointing to that heading’s id. Either approach satisfies
WCAG 2.1 Success Criterion 4.1.2 for accessible
React components.
One thing the library cannot do for you is audit the content you put inside
the modal. A form with unlabeled inputs, images without alt
text, or interactive elements with insufficient color contrast are on your
side of the fence. react-modal gives you the accessible container; what you
do inside it is still your responsibility.
Building a React Modal Form: A Real-World Pattern
The most common practical use case for a
React modal dialog
is a form — login, registration, confirmation, contact, or any interaction
that benefits from a focused overlay rather than a full page navigation.
The following example wires a controlled form inside react-modal, covering
submission, validation feedback, and the most important UX detail: what
happens to your form data if the user dismisses the modal halfway through.
import { useState } from 'react';
import Modal from 'react-modal';
Modal.setAppElement('#root');
const INITIAL_FORM = { name: '', email: '' };
export default function ContactModal() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState(INITIAL_FORM);
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
const validate = () => {
const e = {};
if (!form.name.trim()) e.name = 'Name is required';
if (!/\S+@\S+\.\S+/.test(form.email)) e.email = 'Valid email required';
return e;
};
const handleClose = () => {
setOpen(false);
setForm(INITIAL_FORM); // reset on close — avoids stale data
setErrors({});
setSubmitted(false);
};
const handleSubmit = (e) => {
e.preventDefault();
const e_ = validate();
if (Object.keys(e_).length) { setErrors(e_); return; }
// send to API here…
setSubmitted(true);
};
return (
<>
<button onClick={() => setOpen(true)}>Contact Us</button>
<Modal
isOpen={open}
onRequestClose={handleClose}
className="modal-content"
overlayClassName="modal-overlay"
contentLabel="Contact form"
>
{submitted ? (
<div role="alert">
<h2>Thanks! We'll be in touch.</h2>
<button onClick={handleClose}>Close</button>
</div>
) : (
<form onSubmit={handleSubmit} noValidate>
<h2 id="modal-title">Get in Touch</h2>
<label htmlFor="name">Name</label>
<input
id="name"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
aria-describedby={errors.name ? "name-err" : undefined}
/>
{errors.name && <span id="name-err" role="alert">{errors.name}</span>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
aria-describedby={errors.email ? "email-err" : undefined}
/>
{errors.email && <span id="email-err" role="alert">{errors.email}</span>}
<button type="submit">Send</button>
<button type="button" onClick={handleClose}>Cancel</button>
</form>
)}
</Modal>
</>
);
}
Two details in this pattern deserve attention. First, the handleClose
function resets form state on every close — this prevents a returning user
from seeing half-filled fields from a previous attempt, which is a UX problem
that feels minor until a real user complains about it. Second, error messages
use role="alert" so that screen readers announce them immediately
on injection into the DOM, satisfying WCAG 2.1 criterion 3.3.1.
The success state swaps the form for a confirmation message rendered inside
the same modal, avoiding a full page reload or a jarring navigation. The
role="alert" on the confirmation div ensures the success message
is also announced to assistive technology — a step that is trivially easy
to add and almost universally forgotten.
react-modal Props Reference: The Ones You Actually Need
The full react-modal API surface is larger than its reputation suggests,
but most of it handles edge cases you will rarely encounter. The table
below covers the props that come up in real projects, along with their
types, defaults, and what actually happens when you use them.
| Prop | Type | Default | What it does |
|---|---|---|---|
isOpen |
boolean | — | Controls modal visibility. Required. |
onRequestClose |
function | — | Fired on overlay click & Escape key. You handle the actual close. |
contentLabel |
string | — | ARIA label for the dialog element. Required for accessibility. |
className |
string / object | ReactModal__Content | CSS class for the dialog box. Replaces default if string is passed. |
overlayClassName |
string / object | ReactModal__Overlay | CSS class for the backdrop overlay. |
style |
{content, overlay} |
{} | Inline style objects for content and overlay. |
closeTimeoutMS |
number | 0 | Delays DOM removal — allows CSS exit animations to complete. |
shouldCloseOnOverlayClick |
boolean | true | Set to false to prevent dismiss on backdrop click. |
shouldCloseOnEsc |
boolean | true | Set to false to prevent Escape key dismiss. |
parentSelector |
function | () => document.body | Override the portal target element. |
onAfterOpen |
function | — | Callback fired after modal opens and focus has been set. |
onAfterClose |
function | — | Callback fired after modal closes and focus is restored. |
aria |
object | {} | Additional ARIA attributes: {labelledby, describedby} |
role |
string | “dialog” | Override ARIA role. Use “alertdialog” for destructive confirmations. |
The role prop deserves a special mention. The default
"dialog" is correct for most cases, but if your modal is
presenting a critical or potentially destructive action — deleting an account,
for example — swap it to "alertdialog". Screen readers treat
alertdialog differently: they announce the content immediately and with
higher priority, which is exactly the behavior you want when the user needs
to pay attention before confirming something irreversible.
The aria prop lets you pass labelledby and
describedby as object keys, which map to
aria-labelledby and aria-describedby on the dialog
element. This is how you link the modal’s heading and descriptive text to the
dialog for screen readers, without adding attributes manually to the modal
wrapper you cannot directly access.
react-modal vs. Alternatives: When to Use What
Choosing a React modal library in 2025 means choosing a
tradeoff, not a winner. Every library on this list is production-ready. What
changes is the bundle size, API surface, design opinions, and the kind of
problems each one wants to solve for you.
| Library | Bundle Size | Styled by Default | Accessibility | Best for |
|---|---|---|---|---|
| react-modal | ~8 KB | No | Excellent | Custom-styled, accessible modals with minimal API |
| Headless UI (Radix/Shadcn) | ~12 KB | No | Excellent | Design-system integration, Tailwind projects |
| MUI Dialog | ~200+ KB (whole MUI) | Yes | Good | Projects already using Material UI |
| react-spring + Portal | Custom | No | Manual | Complex, physics-based modal animations |
Native <dialog> HTML element |
0 KB | Browser default | Good (improving) | Minimal deps projects; modern browser targets |
If your project uses Radix UI or Shadcn, the
Radix Dialog primitive
is the more natural fit — it shares react-modal’s headless philosophy and
integrates cleanly with Tailwind. But if you are not in a Radix ecosystem
and do not want to pull in a larger design system just for a modal, react-modal
remains the most focused and battle-tested option available.
The native HTML <dialog> element is worth mentioning as
a zero-dependency alternative. Browser support is now
above 96% globally,
and it handles focus trapping and Escape key behavior natively. The catch is
that styling the backdrop (::backdrop) has quirks, animation
support is limited, and the accessibility story is still inconsistent across
screen readers. For new projects with modern browser targets and a willingness
to handle the rough edges, it is worth evaluating. For everything else,
react-modal is still the safer bet.
Common react-modal Mistakes and How to Avoid Them
Most react-modal bugs fall into a small set of repeating patterns. They are
not difficult to fix — they are just annoying to diagnose the first time
you encounter them.
Forgetting setAppElement() is the single most
common issue. The symptom is a warning in the console and a modal that works
visually but fails for screen reader users. The fix is one line in your root
file. If you are using Next.js, put this call in _app.tsx or in
a client component that is loaded at the app level. If you are server-side
rendering and the call throws because document is not available,
guard it with a check: if (typeof window !== 'undefined').
Managing state outside the component becomes necessary the
moment more than one part of your app can open the same modal. Prop-drilling
isOpen through three component layers to reach a button in a
data table is a sign that this state belongs in a context, a Zustand store,
or whatever global state solution you are using. React-modal has no opinion
on this — it just reads isOpen from wherever you give it.
Scroll not locking on the body is a styling issue that
catches people off guard. React-modal does not add overflow: hidden
to body automatically. If your background content scrolls while
a modal is open, add a CSS class to body via a
useEffect that tracks isOpen, or use the
onAfterOpen and onAfterClose callbacks to toggle it.
The community package
body-scroll-lock
handles this cleanly if you want a ready-made solution.
Semantic Keyword Reference
| Cluster | Keywords Used in Article |
|---|---|
| Primary | react-modal, React modal component, React modal library, React modal dialog, React popup modal |
| Tutorial/Setup | react-modal tutorial, react-modal installation, react-modal setup, react-modal getting started, react-modal example |
| Features | React accessible modal, react-modal accessibility, react-modal styling, React modal form, React dialog component |
| LSI / Semantic | React portal, focus trapping, WAI-ARIA dialog pattern, aria-modal, aria-labelledby, overlay backdrop, controlled form, screen reader, WCAG 2.1 |
Frequently Asked Questions
Install with npm install react-modal, call
Modal.setAppElement('#root') once at the app root, then
use the <Modal> component with three required props:
isOpen (a boolean state), onRequestClose
(a function that sets isOpen to false), and
contentLabel (an ARIA description string).
Everything else is optional.
Yes. react-modal ships with WAI-ARIA compliance: it sets
role="dialog", aria-modal="true",
traps keyboard focus inside the dialog, handles Escape key dismissal,
and restores focus to the trigger element on close. The one manual step
is calling setAppElement() so the library can correctly
hide background content from screen readers.
Use the style prop (an object with content
and overlay keys) for inline styles, or use
className / overlayClassName props to apply
CSS, CSS Module, or Tailwind classes. The library ships with no default
visual styles, so you own the full design. For exit animations, combine
closeTimeoutMS with the automatic CSS hooks
.ReactModal__Content--after-open and
.ReactModal__Content--before-close.