What a Tailwind modal is made of
Nothing in Tailwind is officially called a modal. The overlay is a pile of utility classes (fixed, inset-0, z-50, a translucent backdrop and a centered panel), and every behavior comes from your own markup or script.
| Layer | Typical classes | Job |
|---|
| Backdrop | fixed inset-0 bg-black/50 | Dims the page and catches outside clicks |
| Container | fixed inset-0 z-50 flex items-center justify-center p-4 | Centers the panel in the viewport |
| Panel | w-full max-w-md rounded-lg bg-white p-6 shadow-xl | Holds the title, text and buttons |
The class groups that do the layout work
Because Tailwind is a utility-first framework, each utility maps to a single CSS declaration. That makes the overlay easy to read once you know a handful of them.
fixed inset-0 pins the layer to every edge of the viewport, so page scroll can’t move it.z-50 stacks it above anything with a lower z-index.- Centering the panel on both axes takes
flex items-center justify-center. bg-black/50 paints the backdrop at 50% opacity.
Backdrop opacity in Tailwind v4
Older snippets write the backdrop as bg-black bg-opacity-50.
Tailwind v4 removed bg-opacity-* and replaced it with the slash modifier, so the same backdrop is bg-black/50 (Tailwind CSS upgrade guide, v4.0).
What the classes do not provide
The classes only handle looks. A div with these utilities has no role="dialog", no accessible name, no focus handling and no Escape key, which is why the modal pattern in interface design needs markup and script on top of the styling.
Which method should you use to build a Tailwind modal?
For most projects the native dialog element is the right call. Headless UI earns its place in a React app that needs exit animation or stacked dialogs. Flowbite or daisyUI suit anyone who wants ready-made classes, and Alpine.js makes sense when the page already runs on it.
| Method | Open and close | Outside click | Scroll lock |
|---|
| Native dialog | showModal() and close() | Your own code, or closedby="any" | Manual |
| Headless UI Dialog | open prop and onClose | Built in, fires onClose | Built in |
| Flowbite | Data attributes or Modal JS API | Dynamic backdrop closes it | Body gets overflow-hidden |
| daisyUI | Native dialog, popover, checkbox or anchor | modal-backdrop form | Dialog method locks the background |
| Alpine.js | x-show on an open flag | @click.outside | x-trap.noscroll |
Pros and cons of each method
- The native dialog needs no dependency, and the browser handles the inert background, Escape and an implicit
aria-modal. What it lacks is scroll lock, and exit animation takes extra CSS. - Headless UI ships focus trap, inert background, scroll lock and Escape inside the component (Headless UI v2.1 documentation). The price is a dependency, plus writing every class yourself.
- Flowbite opens modals through data attributes, so you write no script of your own. It does need the Flowbite JavaScript bundle on the page.
- daisyUI puts semantic names such as
modal and modal-box on top of the native dialog. Two of its four methods, checkbox and anchor, can’t close on Escape (daisyUI v5 docs). - Alpine.js has
x-trap, which traps focus and hands it back when the modal closes. The Focus plugin is a separate install, and the Escape and outside-click handlers are yours to write.
Setup on Tailwind v4
Flowbite’s current quickstart adds @import "flowbite/src/themes/default";, @plugin "flowbite/plugin"; and @source "../node_modules/flowbite"; to the main CSS file.
Flowbite moved to Tailwind v4 in its 3.0.0 release and is now on 4.x, so snippets that edit tailwind.config.js belong to the older setup.
When a library is worth it
Installing a library is the obvious move, and it is often unnecessary. MDN lists what custom overlays must replicate from the native element, including the inert background and Escape handling.
Tailwind Labs, the company behind Tailwind CSS, maintains Headless UI and points to Tailwind Plus for predesigned dialog examples built on it (Headless UI documentation, v2.1).
Stay native unless you need stacked modals or exit animation, or your React state is tightly bound to the dialog.
How to build a Tailwind modal with the native dialog element
- Add a
<dialog> with an id and an aria-labelledby value that points to its title. - Put the panel classes on the dialog itself and include
m-auto. Tailwind v4 Preflight resets dialog margins, so the dialog sits in the corner without it (Tailwind CSS upgrade guide). - Style the backdrop with
backdrop:bg-black/50. The ::backdrop pseudo-element appears only when the dialog opens through showModal() (MDN). - Wrap the buttons in
<form method="dialog"> so a click closes the dialog without script, and add autofocus to the button users will press first. - Call
showModal() from the trigger button’s click handler.
<button id="open-modal" class="rounded-md bg-gray-900 px-4 py-2 text-white">Delete project</button>
<dialog id="delete-modal" aria-labelledby="delete-title"
class="m-auto w-full max-w-md rounded-lg p-6 shadow-xl backdrop:bg-black/50 backdrop:backdrop-blur-xs">
<h2 id="delete-title" class="text-lg font-semibold">Delete this project?</h2>
<p class="mt-2 text-sm text-gray-600">This action cannot be undone.</p>
<form method="dialog" class="mt-6 flex justify-end gap-3">
<button value="cancel" autofocus class="rounded-md px-4 py-2">Cancel</button>
<button value="delete" class="rounded-md bg-red-600 px-4 py-2 text-white">Delete</button>
</form>
</dialog>
<script>
document.getElementById("open-modal").addEventListener("click", () => {
document.getElementById("delete-modal").showModal();
});
</script>
One rename to watch for. The v3 class backdrop-blur-sm is now backdrop-blur-xs, and plain backdrop-blur became backdrop-blur-sm.
No polyfill needed
The dialog element has been available across browsers since March 2022 (MDN, page updated October 2026).
Tailwind v4 targets Safari 16.4, Chrome 111 and Firefox 128 (Tailwind CSS upgrade guide), all later than that baseline. Any browser that runs a v4 project already supports showModal().
The same does not hold for newer dialog features such as closedby, covered further down.
Opening without a script
You can skip the script entirely, though each route keeps a different amount of what the native dialog gives you.
- Invoker Commands put
command="show-modal" and commandfor="delete-modal" on the trigger button. MDN marks the API Baseline 2025, newly available since December 2025. - The daisyUI popover method opens through HTML attributes, but background elements stay focusable.
- The checkbox method toggles the modal with a hidden checkbox, and it has no Escape key and no background lock (daisyUI v5 docs).
The checkbox route is the older trick behind many CSS-only modal examples. Prefer Invoker Commands when your audience runs current browsers, and keep the checkbox for legacy support.
Building a Tailwind modal in React with Headless UI
Headless UI traps focus, locks scroll and closes on Escape, so the React side is mostly state. Install @headlessui/react, keep an open flag in useState and render Dialog, DialogBackdrop and DialogPanel with your Tailwind classes.
The component needs a working build first. Setting up Tailwind CSS in a React project comes before the first class applies.
import { useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
export default function DeleteModal() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Delete project</button>
<Dialog open={open} onClose={() => setOpen(false)} className="relative z-50">
<DialogBackdrop className="fixed inset-0 bg-black/50" />
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-semibold">Delete this project?</DialogTitle>
<p className="mt-2 text-sm text-gray-600">This action cannot be undone.</p>
<div className="mt-6 flex justify-end gap-3">
<button data-autofocus onClick={() => setOpen(false)}>Cancel</button>
<button onClick={() => setOpen(false)}>Delete</button>
</div>
</DialogPanel>
</div>
</Dialog>
</>
)
}
What the component handles
- Focus stays trapped inside the dialog while it renders, and the rest of the app is marked inert.
- Escape or a click outside
DialogPanel calls onClose, and you set the state back to false. DialogTitle sets aria-labelledby on the dialog.- The dialog renders in a portal outside your app root, which is how scroll locking stays simple (Headless UI v2.1 documentation).
Enter and exit transitions
Add the transition prop to DialogBackdrop and DialogPanel, then style the closed state with data-closed: classes.
The documented backdrop uses duration-300 ease-out data-closed:opacity-0, and the panel adds data-closed:scale-95 data-closed:opacity-0. The Vue version of Headless UI is still on 1.x, and its docs wrap the dialog in TransitionRoot instead, so check the version before copying a snippet.
Where it breaks
- A click outside
DialogPanel closes the dialog, so padding on the full-screen container counts as outside. - In a scrollable dialog the backdrop must sit behind the scroll container, or the wheel stops working over the backdrop.
The v1 docs say a dialog should contain at least one focusable element. The v2.1 docs say focus lands on the dialog element itself by default, and autoFocus on a Headless UI form control, or data-autofocus on any other element, moves it elsewhere.
Closing a Tailwind modal with Escape, outside click and a close button
The native dialog closes on Escape by itself. Outside click and the close button need a little markup or script, and a div-based modal needs every one of them written by hand.
Escape key
A dialog opened with showModal() closes on Escape by default, and with several modals open only the last one shown closes (MDN).
- Escape fires a
cancel event first, which gives code a chance to prevent the close in some cases. - Div overlays need a keydown listener. In Alpine.js that is
@keydown.escape.window.
Outside click
Each method handles the backdrop differently.
- On a native dialog,
closedby="any" adds light dismiss. MDN’s closedBy page, updated August 2026, still flags it as limited availability and not Baseline. - For a native fallback, move the dialog’s padding to an inner wrapper, then close when the click target is the dialog itself.
- daisyUI uses a second form with the
modal-backdrop class and method="dialog" that covers the screen, so clicking it closes the modal. - In Headless UI, a click outside
DialogPanel calls onClose. - Flowbite’s
dynamic backdrop closes on outside click, and static blocks it.
Close button and form data
Always add a visible button. MDN calls an explicit close button the most dependable route, because phones and tablets may have no Escape key.
Inside a method="dialog" form, a button click closes the dialog, keeps the control states without submitting, and sets returnValue to that button’s value.
A required input blocks the normal close. Add formnovalidate to the cancel button, or call close() from a click handler (MDN).
How do you make a Tailwind modal accessible?
An accessible modal needs a dialog role, an accessible name, contained focus, a working Escape key and focus returned to the trigger. The native dialog element gives you most of that for free. A div-based modal doesn’t.
ARIA attributes for a div-based modal
The native element exposes these semantics itself. A div overlay has to spell them out, following the W3C ARIA Authoring Practices Guide.
role="dialog" goes on the container, with every control the dialog needs sitting inside it.aria-modal="true" tells assistive technology that everything outside is inert.aria-labelledby points to the visible title, and aria-label covers the case where no title is visible.aria-describedby is optional and adds a short description announced with the title.
Leave aria-describedby off when the content holds lists, tables or several paragraphs, because a screen reader reads it as one unbroken string.
Set aria-modal only when the page really blocks everything behind the dialog. The same pattern page warns that a false claim in ARIA attributes has severe consequences for users of some assistive technologies.
Focus on open and on close
When a modal opens, focus moves inside it. The right target depends on the content.
- For long or structured content, put
tabindex="-1" on the title and focus that, so the start of the content stays in view. - Where the last step is destructive, focus the least destructive button, such as Cancel.
- A simple confirmation can focus the most used button, such as OK.
When it closes, focus returns to the element that opened the dialog. If that element no longer exists, focus goes to the next logical element.
A native dialog does this for you: the HTML specification restores focus to the previously focused element when a dialog closes, and current browsers implement it, even though MDN’s dialog and close() pages describe only initial focus. A manual fallback is optional. If you want one anyway, for example as a safety net inside a custom wrapper, a short snippet does it:
const dialog = document.getElementById("delete-modal");
let opener;
document.getElementById("open-modal").addEventListener("click", (event) => {
opener = event.currentTarget;
dialog.showModal();
});
dialog.addEventListener("close", () => opener?.focus());
Escape, Tab and the keyboard trap rule
Two standards seem to pull against each other. The Authoring Practices Guide says Tab and Shift+Tab must not leave a modal dialog, while WCAG success criterion 2.1.2 (Level A) says keyboard focus must never get stuck.
W3C’s Understanding document for 2.1.2, updated 18 May 2026, settles it. A modal that contains focus passes as long as users can leave, and it lists Esc, Cancel or OK as the exits in its modal dialog example.
In practice, contained focus without a working Escape key or a visible close button is a failure, not a feature.
Scroll lock
Two fixes hold up well. Put overflow-hidden on the body while the modal is open, or put overscroll-contain on a scrolling panel, which sets overscroll-behavior: contain and stops scroll chaining into the page.
Both have limits.
- Removing the page scrollbar widens the layout on systems with fixed scrollbars, and everything shifts sideways. daisyUI adds
scrollbar-gutter when a modal opens, and on Firefox you set it on :root yourself (daisyUI v5 docs). - On iOS Safari,
overflow: hidden on the root has not reliably stopped touch scrolling, so the usual workaround pins the body with position: fixed at the negated scroll offset and restores the position on close.
The second gap is a cross-browser compatibility problem, not a Tailwind one, so a desktop-only check will miss it.
Fade and scale animations for a Tailwind modal
On a native dialog, the closed state is opacity-0 scale-95 and the open state is open:opacity-100 open:scale-100. Add starting: classes and transition-discrete on top so the browser animates the display change.
Tailwind’s starting variant generates @starting-style, and transition-discrete sets transition-behavior: allow-discrete (Tailwind CSS docs, v4.3).
<dialog id="modal"
class="m-auto w-full max-w-md rounded-lg p-6
opacity-0 scale-95 transition-all transition-discrete duration-200 ease-out
open:opacity-100 open:scale-100
starting:open:opacity-0 starting:open:scale-95">
...
</dialog>
The backdrop needs a short CSS block that follows MDN’s pattern:
dialog::backdrop {
background-color: transparent;
transition: display 0.2s allow-discrete, overlay 0.2s allow-discrete, background-color 0.2s;
}
dialog:open::backdrop {
background-color: rgb(0 0 0 / 50%);
}
@starting-style {
dialog:open::backdrop {
background-color: transparent;
}
}
Transitions or keyframes for the backdrop
| Approach | Setup | Backdrop fade-out |
|---|
| CSS transitions | @starting-style, plus display and overlay in the transition list with allow-discrete | Works |
| CSS keyframes | display set inside the keyframes, no starting style | Not possible |
MDN’s keyframe example states that the backdrop is removed the moment the dialog closes, leaving nothing to animate. If the backdrop must fade out, use transitions.
Plain CSS keyframes still suit the dialog panel on its own.
Scale stops animating if the transition list names transform
Tailwind v4 builds scale-*, rotate-* and translate-* on the individual CSS properties of the same names.
A custom list such as transition-[opacity,transform] no longer transitions them. Write transition-[opacity,scale] instead (Tailwind CSS upgrade guide).
Reduced motion
motion-reduce:transition-none removes the transition for users who asked for less motion.motion-safe: works the other way round and applies a class only when no reduction was requested, which saves undoing a long class list.
Sizing, responsive layout and dark mode
The panel gets w-full plus a max-w-* value. Breakpoint variants handle placement, and the dark: variant handles colors.
Tailwind’s container scale supplies the common panel widths (Tailwind CSS max-width docs, v4.3).
| Class | Max width | Typical modal |
|---|
max-w-sm | 24rem (384px) | Alert or confirmation |
max-w-md | 28rem (448px) | Short form, default panel |
max-w-lg | 32rem (512px) | Login or settings |
max-w-xl | 36rem (576px) | Multi-field form |
max-w-2xl | 42rem (672px) | Preview or data table |
Responsive placement
Phones usually get a bottom sheet and larger screens a centered panel. Tailwind is built mobile-first, so unprefixed classes apply on phones and sm: starts at 40rem.
The container takes fixed inset-0 flex items-end justify-center sm:items-center sm:p-4, and the panel takes w-full max-w-lg rounded-t-lg bg-white p-6 sm:rounded-lg. daisyUI ships the same idea as modal-bottom sm:modal-middle.
Full screen and scrollable panels
A full-screen modal is a panel with h-full w-full max-w-none rounded-none and no outer padding.
Tall content can scroll with the container or inside the panel.
To scroll the container, give the outer layer fixed inset-0 overflow-y-auto p-4 and add an inner wrapper with flex min-h-full items-center justify-center. Short dialogs stay centered, and long ones move with the page as users scroll.
To scroll inside the panel, put max-h-[85vh] overflow-y-auto on the panel itself. The page behind stays put, and the overlay never grows past the screen.
Dark mode
The dark: variant follows prefers-color-scheme by default (Tailwind CSS dark mode docs). Pairs such as bg-white dark:bg-gray-900 and text-gray-900 dark:text-gray-100 cover the panel.
The backdrop needs its own pair. Flowbite’s documented example uses bg-gray-900/50 with dark:bg-gray-900/80, because a light dim barely registers on a dark page.
For a manual toggle, redefine the variant in your CSS: @custom-variant dark (&:where(.dark, .dark *));
Headless UI renders the dialog in a portal root that sits next to your app root. A dark class on a wrapper inside the app never reaches it, so put the class on the html element. A native dialog keeps its place in the DOM, so an ancestor selector still matches.
Where Tailwind modals break
Most of the trouble comes from stacking contexts that trap z-50 and from v3 snippets whose classes changed meaning in v4. Nested overlays add focus problems on top of that.
z-index and stacking contexts
A z-index only competes inside its own stacking context. Several common utilities create new ones (MDN, stacking context page, updated September 2026):
scale-*, rotate-* and translate-*blur-* and backdrop-blur-*opacity-* below 100fixed itself, which always creates one
Flowbite’s defaults show the effect. Its backdrop sits at z-40 and its modal at z-50, so a sticky header at z-50 stays above the backdrop, bright and clickable.
Move a div-based modal near the end of the body, or use a native dialog. Elements in the top layer sit above every other layer in the document (MDN, top layer glossary).
Nested modals
A modal opened from inside another modal needs its own focus handling.
- With native dialogs, the one opened last appears above the others.
- Alpine.js
x-trap supports nesting, and each trap returns focus to where it was before it activated. - The Authoring Practices Guide ships an example with several layers of modal dialogs as its reference pattern.
Tailwind v3 snippets that break in v4
| v3 habit | v4 behavior | Fix |
|---|
focus:outline-none | Sets outline-style: none, so the focus indicator can vanish in forced colors mode | outline-hidden |
focus:ring | 1px wide in currentColor, not 3px blue | focus:ring-3 focus:ring-blue-500 |
hidden attribute with a flex class | Display classes no longer override the attribute | Remove the attribute to show the modal |
Every row comes from the Tailwind CSS upgrade guide, and old modal snippets lean on all of them.
Tailwind Modal FAQ
How do you build a Tailwind modal in Vue?
Put a template ref on a native dialog element and call showModal() from the click handler.
For a div-based modal, wrap it in the Teleport component with a body target. Vue’s documentation warns that an ancestor with a CSS transform, perspective or filter breaks a modal’s layout.
Does shadcn/ui replace a hand-built Tailwind modal?
Yes, in React projects that already use it. Its Dialog is a styled wrapper over a headless primitive: Base UI for new projects since July 2026, React Aria as a third option, or Radix Dialog if you choose Radix or started earlier. The Radix primitive traps focus, closes on Esc and announces the dialog through Title and Description (Radix UI documentation).
In other stacks it repeats what the native element or Headless UI already provides.
How do you reset a form inside a Tailwind modal when it closes?
Call the form’s reset() method inside a listener for the dialog’s close event, which fires after the dialog has closed.
A form with method=”dialog” saves its control states instead of submitting them (MDN), so typed values survive the close until you clear them.
What is the difference between a modal, a drawer and a popover?
It comes down to how much of the page stays usable. A modal makes everything behind it inert, while a popover is non-modal, so users can still reach the background (MDN).
A drawer changes placement, not behavior: it is a modal or non-modal panel anchored to a screen edge.
Testing a Tailwind Modal Before It Ships
A Tailwind modal passes testing when a keyboard user can open it, tab through every control, close it with Escape and land back on the trigger.
Run the checks in this order, because a focus failure makes any later screen reader result unreliable.
- Start with the keyboard. Open it, Tab and Shift+Tab through it, press Escape and watch where focus lands.
- Then a screen reader, to hear whether the dialog name is announced on open.
- A real phone goes last, for scroll lock and bottom sheet placement.
Each layout variant needs its own pass, so a centered panel and a bottom sheet mean two rounds of checks.
As of 29 October 2026, closedby has not reached Baseline. Once it does, the outside-click script can be deleted and one test case disappears with it.
The same keyboard and screen reader passes apply to every other overlay and widget on the page.