tailwind-react-modal
v3.1.0
Published
Accessible, animated React modal for Tailwind CSS 4 — eleven shipped themes (terminal, brutalist, glass, editorial, neon, minimal, pop, liquid, command, elevated), ready-made and composable layouts, focus trap, inert background, mobile bottom sheet, dark
Maintainers
Readme
tailwind-react-modal
An accessible, animated React modal built with Tailwind CSS — zero runtime dependencies beyond React itself.
Use one of the three ready-made layouts (message, confirm/cancel, form wrapper), or
compose your own content with Modal.Header / Modal.Body / Modal.Footer. The
hard parts are already done for you:
- Accessibility —
role="dialog",aria-modal, automatic labelling, focus trap, focus restore, and aninertbackground so keyboard and screen-reader users can never wander behind the modal. - Animations — the backdrop fades while the panel pops in and out on separate
timings, including a real exit animation before unmount.
prefers-reduced-motionis respected. - Themes — eleven looks ship with the package:
neutral,terminal,brutalist,glass,editorial,neon,minimal,pop,liquid,command,elevated. One prop, no Tailwind config, and your own classes still override them. - Polish — blurred backdrop, scroll lock that compensates the scrollbar width so the page never shifts, a top-right "X" button, dark mode, and an optional bottom-sheet presentation on mobile.
The eleven themes
Anything you pass through classNames still wins over a theme, so these
are starting points, not cages. Full details below.
Requires React 18 or 19 and Tailwind CSS 4.
Table of contents
- The eleven themes
- Installation
- Quick start
- The three variants
- Composable API
- The useModal hook
- Mobile bottom sheet
- Themes
- Behaviour & accessibility
- Sizes and stacking
- Animations
- Styling
- Dark mode
- Migrating from v2
- Migrating from v1
- Running the examples
- Contributing
Installation
npm install tailwind-react-modalImport the component and the stylesheet once:
import { Modal } from 'tailwind-react-modal'
import 'tailwind-react-modal/styles.css'Import it after your own Tailwind entry point. The neutral skin is plain
utilities, so it competes with yours at equal specificity and load order decides —
with the order reversed, a stray bg-white from your build would override the
library's dark: defaults. Themes are not affected by this: they live in
the components layer and always lose to your utilities, whatever the order.
Quick start
import { Modal, useModal } from 'tailwind-react-modal'
import 'tailwind-react-modal/styles.css'
function App() {
const { isOpen, open, close } = useModal()
return (
<>
<button onClick={open}>Show me</button>
<Modal
variant='simple'
isOpen={isOpen}
title='CONGRATULATIONS!'
message='Your modal is working.'
closeLabel='Close'
onClose={close}
/>
</>
)
}Everything is driven by two props: isOpen decides whether the modal shows, and
onClose is called for every dismissal — the close button, the "X", the Escape
key, and the backdrop click. Keep isOpen mounted and toggle it (rather than
conditionally mounting the modal) so the exit animation can play.
The three variants
The shots below run the neon theme — its gradient hairline and halo — over the
plain variant markup, so theme='neon' is the only difference from the snippets.
simple — a message and a close button
<Modal
variant='simple'
isOpen={isOpen}
title='CONGRATULATIONS!'
message='Your modal is working.'
closeLabel='Close'
onClose={close}
/>approval — confirm or cancel
<Modal
variant='approval'
isOpen={isOpen}
title='WARNING!'
message='Are you sure?'
approveLabel='Yes do it !'
closeLabel='Cancel'
onApprove={remove}
onClose={close}
/>onCancel is optional; the cancel button falls back to onClose. The warning icon
ships inline — pass any node to warningIcon to replace it. Focus lands on the
cancel button when the modal opens, so a reflex press of Enter can never trigger
the destructive action, and the confirm button — not the cancel one — carries the
destructive red.
form — wrap your own form
<Modal
variant='form'
isOpen={isOpen}
ariaLabel='Signup form'
formComponent={<MyForm onClose={close} />}
onClose={close}
closeOnBackdropClick={false}
/>A form has no title, so give the dialog an accessible name with ariaLabel. Turning
off closeOnBackdropClick avoids losing input to a stray click.
Composable API
Skip variant entirely and bring your own content. Modal.Header and Modal.Body
are wired into the dialog's aria-labelledby / aria-describedby automatically, and
Modal.Footer lays out your buttons.
<Modal isOpen={isOpen} onClose={close} size='lg'>
<Modal.Header>Composable modal</Modal.Header>
<Modal.Body>
Any content you like. Header, Body and Footer are optional and each
accepts a className.
</Modal.Body>
<Modal.Footer>
<button onClick={close}>Cancel</button>
<button onClick={confirm}>Confirm</button>
</Modal.Footer>
</Modal>The useModal hook
A tiny optional helper so you don't have to write the same useState in every
component:
const { isOpen, open, close, toggle } = useModal()
// useModal(true) starts openMobile bottom sheet
With mobileSheet, viewports below Tailwind's sm breakpoint get a full-width
panel docked to the bottom edge that slides up — the familiar mobile sheet pattern.
Larger viewports are unaffected.
<Modal isOpen={isOpen} onClose={close} mobileSheet>
<Modal.Header>Mobile sheet</Modal.Header>
<Modal.Body>Docked to the bottom edge on small screens.</Modal.Body>
</Modal>Behaviour & accessibility
The modal renders through a portal into document.body, so it can't be clipped by an
overflow-hidden or transformed parent. While open, it:
- exposes itself as
role="dialog"/aria-modal="true", labelled by its title and described by its message; - moves focus inside and keeps Tab / Shift+Tab cycling within the dialog;
- marks everything else on the page
inert, so assistive tech can't escape it; - locks background scrolling and compensates the scrollbar width, so the page layout doesn't shift behind the backdrop;
- returns focus to whatever was focused before it opened.
It is dismissed by the Escape key, the close/cancel button, the top-right "X", or a
backdrop click — all funnelled into your single onClose.
| Prop | Default | Effect |
| --- | --- | --- |
| isOpen | — | false hides the modal (with an exit animation); omit to control mounting yourself |
| onClose | — | Called for every dismissal |
| closeOnBackdropClick | true | Dismiss when the backdrop is clicked |
| closeOnEscape | true | Dismiss on the Escape key |
| showCloseButton | true | Render the "X" button in the top-right corner |
| closeButtonAriaLabel | 'Close dialog' | Accessible name of the "X" button |
| ariaLabel | — | Accessible name when nothing else provides a title |
| usePortal | true | Render into document.body rather than in place |
| portalContainer | document.body | Portal target |
Sizes and stacking
| Prop | Default | Effect |
| --- | --- | --- |
| size | 'md' | Panel width: 'sm', 'md', 'lg', 'xl' or 'full' (full screen) |
| zIndex | 50 | z-index of the modal root |
| mobileSheet | false | Bottom-sheet presentation below the sm breakpoint |
Stacked modals are supported: each one adds inert around itself and the scroll lock
is reference-counted, so closing the top modal restores exactly the state underneath.
Animations
By default the backdrop fades (animate-fade-in / animate-fade-out) while the
panel pops (animate-pop-in / animate-pop-out), on separate timings. Override
either side with any animation class — animate-fade-in-up, animate-fade-in-down
and animate-fade-in also ship with the stylesheet:
<Modal animation='animate-fade-in-up' exitAnimation='animate-fade-out' ... />Two things worth knowing:
- The exit animation plays when
isOpenflips tofalse; the modal unmounts itself once the animation ends. If you mount the modal conditionally ({show && <Modal/>}), there is nothing left to animate, so the exit is skipped. - Users with
prefers-reduced-motionget no animation at all and an instant close.
Themes
Eleven looks ship with the package — pictured at the top. Pick
one with the theme prop:
<Modal theme='terminal' isOpen={isOpen} onClose={close}>
<Modal.Body>Nothing else to configure.</Modal.Body>
</Modal>The registry is exported, so you can build a picker or read a theme's description:
import { modalThemes, modalThemeNames } from 'tailwind-react-modal'
modalThemeNames.map((name) => modalThemes[name].label)Why this needs no Tailwind config on your side. The library compiles its own
stylesheet, so every preset is already in tailwind-react-modal/styles.css. Themes
are not bags of utility classes handed to your build — they are named classes
resolved in Tailwind's components layer.
That layer sits below utilities in the cascade, which is the point: anything you
pass through classNames beats a theme automatically, whichever stylesheet loads
first and with no ! suffix.
// A terminal window with a green confirm button.
<Modal
theme='terminal'
variant='approval'
classNames={{ approveButton: 'bg-emerald-600 hover:bg-emerald-500' }}
/>Window chrome
The terminal theme renders a title bar with three macOS-style lights, the red one
being the close button. Any theme can have one:
<Modal titleBar titleBarLabel='guillaume@web — qa & dev' />
<Modal theme='terminal' titleBar={false} /> {/* suppress it */}
<Modal titleBar={<MyOwnBar />} /> {/* replace it */}titleBarLabel falls back to title. When the bar is on it replaces the corner "X",
so there is still exactly one dismiss control in the tab order.
The blinking caret the terminal theme appends to variant messages is available to
free content as the rtm-caret class.
Styling
Every visual slot accepts extra Tailwind classes through classNames, appended after
the defaults:
<Modal
classNames={{
root: '', // fixed full-screen wrapper
backdrop: 'bg-indigo-950/50', // the blurred overlay
panel: 'rounded-3xl', // the white rounded panel
content: 'p-8', // the padded wrapper inside the panel
titleBar: 'bg-slate-800', // the window chrome, when shown
title: 'text-indigo-900', // variant title
message: 'text-base', // variant message
icon: 'bg-amber-100', // the approval warning badge
closeButton: 'bg-slate-600', // close/cancel button
approveButton: 'bg-indigo-600', // approve button
dismissButton: 'text-slate-500' // the top-right "X"
}}
...
/>Modal.Header, Modal.Body and Modal.Footer each accept their own className
too.
Classes you pass must be reachable by your Tailwind build, not the library's —
they end up in your markup, so your @source globs must cover the files where you
write them.
The v2.0 colour props (modalBackground, successTitleColor, closeButtonBgColor,
buttonsTextColor, …) still work but are deprecated in favour of classNames.
Changed in 2.2 — the default palette is neutral: the close/cancel button is an
outlined secondary button instead of a solid red one, the approval confirm button
carries the destructive red, and the simple title is no longer uppercase lime. Pass
the colour props or classNames to get the old look back.
Dark mode
Dark mode is driven by a .dark class on any ancestor (not the OS preference), which
plays nicely with theme togglers:
Every theme carries its own dark mode, not just a recolour — see the gallery at the top of this README.
<html class="dark">Migrating from v2
The package was renamed. Nothing else changed — every prop, variant, slot and export works exactly as it did in 2.2.
-npm install react-tailwind-flex-modal
+npm install tailwind-react-modal-import { Modal, useModal } from 'react-tailwind-flex-modal'
-import 'react-tailwind-flex-modal/styles.css'
+import { Modal, useModal } from 'tailwind-react-modal'
+import 'tailwind-react-modal/styles.css'react-tailwind-flex-modal stays installable and stops at 2.1.0, its last published
version; every release from here lands under the new name.
New in 3.0: themes, the titleBar / titleBarLabel props, and the
content and titleBar classNames slots.
Migrating from v1
The full list of changes is in the v2.0.0 release notes.
v1 props still work. They are marked @deprecated and resolve to the new ones, so
existing code keeps running — but three things do change and are not shimmed:
- The stylesheet moved.
tailwind-react-modal/dist/index.cssis nowtailwind-react-modal/styles.css, and importing the component no longer pulls the CSS in as a side effect. You must update this import. - Tailwind 4 is required. v1 was built against Tailwind 2.
- The package is ESM-first with an
exportsmap (CJS still ships). Bundlers with noexportssupport will break.
| v1 | v2 |
| --- | --- |
| currentModal='simpleModal' | variant='simple' |
| currentModal='aprovalModal' | variant='approval' |
| currentModal='formModal' | variant='form' |
| setShowModal + handleClose | onClose |
| handleAproval | onApprove |
| handleCloseAproval | onCancel |
| successTitle / warningTitle | title |
| warningMessage | message |
| closeMessage | closeLabel |
| aprovalMessage | approveLabel |
| aprovalButtonBgColor | approveButtonBgColor |
| darkAprovalButtonBgColor | darkApproveButtonBgColor |
Note the spelling fix: aproval → approval.
One behaviour difference worth knowing: in v1, Escape called setShowModal(false)
directly and left your currentModal state untouched. That quirk is preserved for
setShowModal users, but once you move to onClose every dismissal — button, Escape,
backdrop — runs the same handler.
Running the examples
cd examples
npm install
npm run devThe example app consumes the library from source via file:.. and demonstrates every
variant, the composable API, the mobile sheet and the useModal hook. The
screenshots in this README are taken from it.
It also carries a theme picker, so every shipped preset is one click away. The page
itself keeps its own design system; the modals just take a theme prop.
Contributing
Building the package needs nothing special, but the test suite requires Node 22.22+
or 24.15+ — jsdom 30 does not run on Node 20. engines stays at >=18 because that
is what consuming the published browser bundle needs.
npm ci
npm test # types, unit, lint, buildLicense
MIT © Guillaume-da
