Tailwind Modal Component: Dialog, Alert, Drawer Variants
Build dialog, alert, and drawer modals with Tailwind CSS v4. Real code, accessible markup, and variant patterns you'll actually use in production.
Why Modal Components Are Still Hard to Get Right
Honestly, modals are one of those UI patterns that look simple until you're three hours in, debugging a focus trap on iOS Safari while your backdrop click handler fires twice. Every app needs them. Dialog boxes, confirmation alerts, side drawers — the whole family. Yet most implementations cut corners somewhere.
Tailwind v4.0.2 doesn't ship pre-built modal components, which is intentional. It's a utility framework, not a component library. That said, the utilities it provides — backdrop-blur, transition-all, data-[state] variants — make building a solid modal from scratch faster than you'd expect.
This article covers three modal variants: a centered dialog, a destructive alert dialog, and a right-side drawer. All built with Tailwind utility classes, all accessible, all production-ready. No third-party component library required, though you can absolutely layer these patterns on top of Radix or Headless UI if you want the accessibility primitives handled for you.
Base Modal Structure: Overlay and Panel
Every modal starts with two elements: the overlay (the darkened backdrop) and the panel (the floating content box). Get those two right and everything else — animations, sizing, positioning — is just variation on top.
The overlay needs position: fixed, full viewport coverage, and a z-index that clears your navigation. The panel sits centered inside it, either via flexbox on the overlay or absolute positioning. Here's the base pattern in Tailwind with Tailwind v4.0.2 syntax:
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div className="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-zinc-900">
{children}
</div>
</div>
);
}Notice backdrop-blur-sm on the overlay. That's blur(4px) under the hood. You can push it to backdrop-blur-md (12px) for a frosted glass effect — especially nice if you've read about glassmorphism techniques and want that layered depth on dark backgrounds. The bg-black/60 sets opacity via Tailwind's slash syntax, resolving to rgba(0,0,0,0.6) at build time.
Centered Dialog Variant with Animation
The basic dialog is what users expect when they click 'Edit' or 'Add item'. It's centered, dismissible via backdrop click or Escape key, and shouldn't scroll the page behind it. That last part — scroll locking — is the detail most tutorials skip.
For scroll locking, you need a useEffect that adds overflow-hidden to document.body when the modal opens and cleans it up on close. Tailwind can't do this for you. It's two lines of JavaScript but it matters every time a user is in the middle of a long page.
For the animation, use Tailwind's transition utilities combined with conditional class toggling. The data-[state=open] pattern from Tailwind v4 is cleaner than ternary-based class strings. Alternatively, wrap with a library like framer-motion and apply initial={{ opacity: 0, scale: 0.95 }} with animate={{ opacity: 1, scale: 1 }} — that 0.95 to 1 scale transition over 150ms is the sweet spot. Anything longer starts feeling sluggish.
Alert Dialog Variant: Destructive Actions
Alert dialogs are different from regular dialogs in one important way: they demand a response. You can't dismiss them by clicking the backdrop. Delete confirmations, payment confirmations, anything irreversible — these go in an alert dialog. The ARIA role changes to alertdialog and focus should land on the least destructive action by default.
The visual treatment should signal danger without being obnoxious. A red-tinted panel border works well: ring-1 ring-red-500/30 on the panel with a bg-red-50 dark:bg-red-950/20 header section. Keep the destructive button in red (bg-red-600 hover:bg-red-700) and the cancel action as a ghost button so the visual hierarchy is unmistakable.
export function AlertDialog({ open, onConfirm, onCancel, title, description }: AlertDialogProps) {
return (
<Modal open={open} onClose={() => {}} disableBackdropClose>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
<svg className="h-5 w-5 text-red-600" /* warning icon */ />
</div>
<div>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
{title}
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={onCancel}
className="rounded-lg border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300"
autoFocus
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Delete
</button>
</div>
</div>
</Modal>
);
}The autoFocus on the Cancel button is intentional. Most users hitting delete accidentally will press Enter — autofocusing Cancel means that Enter dismisses rather than confirms. It's a small thing that prevents a lot of support tickets.
Drawer Variant: Side Panel with Slide Animation
Drawers are modals that slide in from an edge — usually right, sometimes left, occasionally bottom for mobile. They're better than centered dialogs when the content is form-heavy or list-heavy and benefits from vertical space. Shopping cart sidebars, filter panels, user profile sheets.
The key difference in markup is that the panel is no longer centered. It's pinned to one edge with right-0 top-0 h-full and a fixed width — w-80 (320px) is the standard, w-96 (384px) for wider content. The slide animation uses translate-x-full as the hidden state and translate-x-0 as the visible state, both wrapped in transition-transform duration-300 ease-out.
One thing people often miss: the drawer should still have aria-modal="true" and trap focus within it. Without focus trapping, keyboard users Tab out of the drawer into the background content — which is now inert and shouldn't receive interaction. If you're not using Radix Dialog or Headless UI Dialog, you'll need to implement this manually or reach for the focus-trap-react package.
For consistent component patterns across your Tailwind project, it's worth defining the drawer width as a design token rather than a hardcoded class. Something like --drawer-width: 384px in your CSS layer, then w-[var(--drawer-width)] in Tailwind. This way your sidebar nav and your drawer stay in sync automatically.
Handling Dark Mode and Theming
If your app has a theme toggle, your modal needs to respond to it correctly. The default Tailwind dark mode setup using class strategy means your modal panel classes need explicit dark: variants on every color — bg-white dark:bg-zinc-900, text-zinc-900 dark:text-zinc-100, border-zinc-200 dark:border-zinc-800.
With Tailwind v4, you can use CSS custom properties to reduce that duplication. Define a --surface token in your :root and [data-theme='dark'] selectors, then use bg-[var(--surface)] in your components. That single class handles both themes without a dark: duplicate. It's cleaner at scale — if you've explored Tailwind v4's new features, you'll know the native CSS variable support makes this pattern much more ergonomic than it was in v3.
The backdrop blur also needs a fallback. Not every browser supports backdrop-filter. Add @supports not (backdrop-filter: blur(1px)) { .modal-backdrop { background: rgba(0,0,0,0.75); } } so users on unsupported browsers still get a properly darkened overlay, just without the blur effect.
Accessibility Checklist for Tailwind Modals
Let's be real: a modal that looks right but isn't accessible isn't finished. Here's what you actually need to check before shipping. Focus management: does focus move into the modal when it opens? Does it return to the trigger element when the modal closes? Does the Tab key cycle only within the modal?
Screen reader announcements: role="dialog" plus aria-labelledby pointing to your modal's title element. For alert dialogs, role="alertdialog". The aria-modal="true" attribute tells screen readers to treat everything outside the modal as inert — but note this is still buggy in some VoiceOver versions, so combining it with actual inert attribute on background content is more reliable.
Keyboard interactions: Escape closes the modal (except alert dialogs where it should do nothing or focus Cancel). Enter on a form inside the modal submits that form, not the parent page. Click outside (backdrop) closes regular dialogs, not alert dialogs. And — this one gets missed constantly — if the modal contains a scrollable list, arrow keys should scroll that list, not the browser window.
Color contrast on modal content isn't exempt from WCAG just because it's floating. Check your modal text against its background specifically, not just your page defaults. The zinc-500 text on white background in the description slots often needs to go up to zinc-600 to clear the 4.5:1 ratio requirement.
Putting It Together: Modal Manager Pattern
Once you have three or four modal variants, you'll want a way to manage which one is open without littering useState hooks across every component that might trigger a modal. The modal manager pattern uses React Context to hold a queue of modal states and exposes openModal / closeModal functions to any component in the tree.
It works like this: your ModalProvider renders at the top of your app and subscribes to a Zustand store (or a useReducer — your call). Any component calls openModal({ type: 'alert', props: { title: 'Delete item?', ... } }). The provider reads that, picks the right variant component, and renders it. This is especially useful in larger apps where a table row, a notification, and a button in three different subtrees all need to trigger the same delete confirmation.
The tradeoff is indirection — debugging is harder when the modal render is decoupled from the call site. For apps with just one or two modal types, co-located state is fine. For anything with five or more distinct modal types, the manager pattern pays for itself quickly. Worth combining with Tailwind container query patterns if your modal layout needs to adapt based on the container it's rendered in rather than the viewport size.
FAQ
In a useEffect, add document.body.classList.add('overflow-hidden') when open is true and remove it in the cleanup function. Tailwind's overflow-hidden utility maps to overflow: hidden on body, which prevents scroll. Make sure the cleanup runs on unmount too, not just when open changes to false.
Yes. Use Tailwind's transition utilities (transition-all duration-200) combined with conditional class swapping via a boolean state. For entry: start with opacity-0 scale-95 and swap to opacity-100 scale-100 on mount using a one-frame setTimeout to trigger the CSS transition after the element is in the DOM. It's not as ergonomic as framer-motion but works without a dependency.
Use role="alertdialog" instead of role="dialog". This tells assistive tech that the dialog requires user action and cannot be dismissed without responding. Pair it with aria-labelledby pointing to the dialog title and aria-describedby pointing to the description text.
w-80 (320px) is the common default for narrow drawers like cart sidebars. w-96 (384px) suits form-heavy drawers. For mobile, most drawers should go full-width: add w-full sm:w-80 so the drawer takes the full screen on small viewports and snaps to 320px on sm and above.
Add a keydown event listener in a useEffect: const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);. Only add this for regular dialogs — alert dialogs should either ignore Escape or focus the Cancel button instead of closing.
The utility names are the same (backdrop-blur-sm, backdrop-blur-md, etc.) but Tailwind v4 generates them as native CSS backdrop-filter rules without the -webkit- prefix fallback by default. If you need Safari 14 support, you may need to add the vendor-prefixed version manually in a custom CSS layer.