36 Tailwind CSS modals for auth and password reset, paywalls and subscribe walls, product quick-views and cart drawers, age gates and GDPR consent, delete confirmations and support forms, plus a ⌘K command palette — spanning native <dialog> overlays, :has() and :target zero-JS state machines, slide-over drawers, bottom sheets, and radio-driven tabs. Scoped under .tm-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
Building a whole Tailwind app, not just this component?
You just browsed 36 Tailwind modals. Untitled UI Pro unlocks 5,000+ components and sections and 250+ page examples for React, styled with Tailwind CSS v4. One licence is a one-time $349 with lifetime updates; teams start at $999.
The reference-grade auth overlay: one native <dialog> flips between Sign in and Create account with zero-JS tabs, floating labels and a blurring backdrop. The port's win is open: + open:starting: + transition-discrete — the closed state, the open state and the frame the browser paints before the transition all sit in one class list on the element that animates.
A single-input recovery dialog that swaps in place to a neutral 'check your inbox' panel. The port replaces the original's hidden-toggling JavaScript with one data-view attribute on the dialog and two data-[view=…]: variants — the state lives in the DOM where the styles can read it, and the controller shrinks to one assignment.
A full-screen entry gate that blurs and blocks the page until the visitor confirms their age — still zero JavaScript, now keyboard-operable. The port reads one checkbox from two directions: has-checked: on the wrapper un-blurs the article, and peer-checked: on the gate fades it out.
A subscribe wall that fades the article tail into nothing with a gradient mask and blurs whatever is left behind a heavy ::backdrop. Two arbitrary properties do the work Tailwind has no scale for — [mask-image:…] and the backdrop composition — and everything else is utilities.
A two-column quick view — gallery, rating, colour and size chips, quantity stepper, Add to bag — that a shopper can evaluate without leaving the grid. Variant selection stays zero-JS: has-checked: on each label reads the radio inside it, so the chip styles itself.
A right-edge mini-cart that slides in over an inert page: line items, a free-shipping progress bar, a live subtotal and a checkout CTA. The slide is translate-x-full → open:translate-x-0 with open:starting:translate-x-full — in and out, with no JS timers and no transition-end listeners.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
The conversion popup, rebuilt on v4: a cream card with a gradient cap, one email field and a strong CTA, opened once after a delay and then never again this session. The port moves the palette out of arbitrary neutral-* picks into @theme tokens, so the card retints from one line.
An apparel size chart that opens from a text link and flips between centimetres and inches — no JavaScript in either interaction. Two checkbox/radio gates, read with group-has-checked: and group-has-[#tm-08-in:checked]:, do what two :has() rules did before.
A six-frame gallery whose full-screen viewer is pure CSS: every panel is an :target, every control is an anchor. The port's whole story is two variants — target: on the panel and group-target: on the figure inside it — replacing a pair of :target rules written far from the markup.
A click-to-play trailer overlay: a 16:9 box locked by aspect-video before anything loads, and an iframe whose src is set on open and cleared on close so audio stops the instant the dialog does. Zero layout shift, zero orphaned audio.
The friction that prevents an accidental account deletion: plain-language consequences, a keyword the user must type, and a Delete button that stays genuinely disabled until they do. disabled:opacity-40 disabled:cursor-not-allowed styles the gate; the attribute enforces it.
The result modal: a tick that draws itself on success, a cross that draws and shakes on error. One data-state on the dialog drives both, read by group-data-[state=ok]: and group-data-[state=err]: on the SVG parts — no class juggling in the controller.
A corner consent sheet with equal-weight Accept and Reject, a <details> preferences panel and real toggle switches. The port deletes state-tracking JavaScript twice over: the disclosure becomes a <details> read by open:, and the slide-in becomes data-in read by data-[in]:.
A three-step product tour in a dialog: art, one idea per panel, progress dots and a Skip that always works. One data-step on the card selects the panel, the dot and the button labels, so the controller sets a number and nothing else.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
One dialog, two shapes: a right-docked full-height panel on desktop, a rounded bottom sheet under 520px — same markup, same transition contract, different axis. The responsive half of the port is max-[520px]: stacked in front of the open: and open:starting: variants.
A support form that opens over the dashboard instead of navigating away: two-column identity fields, a topic select, priority radios and a counted message box. Validation styling uses user-invalid:, so a field only turns red after the user has actually left it wrong.
A preferences dialog with a vertical nav and three panels, switched entirely by radios: no tab controller, no aria-selected bookkeeping. group-has-[#tm-17-appear:checked]: selects both the active nav item and its panel, from the class list of each.
The ⌘K launcher: type to filter, arrow to move, Enter to run. The port's neat trick is that the active row needs no class at all — aria-[selected=true]:bg-tm18-acctint styles the attribute the screen reader is already reading, so the highlight and the announcement cannot disagree.
A destructive-action confirmation whose card swings in from the bottom-right at 3° and settles square. The port keeps the muted intensity of the original — a spring easing token, not a bounce — and states the whole entrance as open: plus open:starting: on the dialog, so the closed state, the open state and the pre-transition frame are one class list.
A SaaS sign-in whose shell appears crisp and whose contents develop like a photograph — brand, title, social row, divider, fields and submit each clearing an 8px blur on a staggered offset. The port turns the original's eleven nth-child delay rules into one animate-tw02-drop theme utility plus an [animation-delay:…] on each child.
A registration card whose three horizontal panels — header, form, benefits strip — fan down like origami, each starting folded back on the X axis. The port uses v4's perspective-*, transform-3d and backface-hidden utilities for the 3D stage and one animate-tw03-fold theme animation with three [animation-delay:…] offsets.
A card-entry checkout that unfolds from the top like an envelope — rotate-x-[-70deg] to flat, ease-out, no overshoot, because payment wants precision rather than bounce. The port states the fold as open:starting: on the dialog inside a perspective-* stage and keeps the original's abandonment guard on <kbd>Esc</kbd>.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
A patient authorization sheet whose entrance is deliberately nothing: a 200ms opacity fade, no transform, because decorative motion measurably lowers perceived security on medical consent. The port's real work is the gate — has-[#tw-05-agree:checked]: plus a scroll flag turns the consent button on only when the patient has both read and agreed.
A 21+ gate that opens like a camera iris — clip-path: circle(0%) expanding past the card's corners. Tailwind ships no clip-path scale, so the port uses arbitrary properties on the two states, which is the idiomatic answer: an iris radius is not a design-scale value.
A consent centre whose four category rows unfold downward like origami while the title and the three actions are readable from frame one — the fast path stays fast. The port drives the cascade from the dialog's own [open] state with in-[dialog[open]]: plus per-row [transition-delay:…], and builds the toggles from peer-checked: instead of custom controls.
A cinema-dark video overlay that materialises through a stuttered RGB split — red and cyan bands offset for four stepped frames, then gone. The port keeps the two pseudo-element clones as before:/after: utilities and registers the stutter as an --animate-* theme entry, because steps() timing is the whole effect.
A portfolio lightbox that opens through a diamond aperture — four polygon vertices expanding from the centre past the frame's corners. The port states both polygons as arbitrary properties on the stage, adds real arrow-key navigation the original only described, and keeps the counter and caption legible on near-black.
An e-commerce confirmation that blobs into existence: an SVG goo filter plus a 0.6→1 spring, with the filter dropped the instant the card settles so text renders crisp. The port carries the filter as [filter:url(#tw-10-goo)] and switches it off with open:[filter:none], keeping the whole effect in the class list.
A browse-mode product preview that expands from a button-sized pill into a full card — scale, translate and border-radius animating together on an elastic curve. The port states all three on the dialog as open:starting: values, so the morph's start shape lives beside its end shape.
An editorial subscribe card that arrives in layers — a red disc drifting up behind, the card settling a beat later, the backdrop blurring first. The port expresses the depth as two elements with different [transition-delay:…] and different curves, all read from the dialog's [open] state.
A last-chance offer that morphs in at a tilt — goo filter, 0.4 scale, −4° rotate, settling on an overshoot curve. The port makes the two things the original only described real: the countdown actually counts down (persisted for the session, so it cannot be gamed by a reload) and the coupon copies to the clipboard.
An OpenTable-style booking sheet whose three panels — date, time, party — fan down in sequence. The port keeps the cascade as one animate-tw14-fan theme animation with three delays, and drives every selection from aria-[checked=true]: so the chips' look and their announced state are the same fact.
The peak-dopamine moment: the card springs from 12% scale while twelve CSS particles fire outward on cos()/sin() paths. No canvas, no particle library — each span carries its own angle, distance and colour as custom properties, and the port keeps them as inline --* values because per-particle randomness is data, not design tokens.
A first-run welcome whose contents develop in sequence — stepper, illustration, headline, copy, CTA — over a deliberately unhurried 1.3 seconds. Same cascade technique as demo 02, applied to a moment that can afford the time, with the primary action clickable from the first frame rather than at the end of the choreography.
Post-signup legal consent with the collection's most restrained motion: a 20px slide down and a fade, ease-out, no overshoot — a document placed on a desk. The gate is the substance: Accept stays disabled until the agreement box is ticked, driven by has-[#tw-17-agree:checked]:, with marketing consent kept separate.
The collection's closing bookend: demo 01's corner swing reused on a warmer palette, proving the entrance library travels. The port adds what the original only warned about — a neutral, enumeration-safe confirmation that swaps in place via one data-view attribute and announces itself to screen readers.
FAQ
Frequently asked questions
How do these Tailwind modals open and animate without a JavaScript animation library?
Every dialog demo opens with the native <dialog> element and its showModal() method, which gives you a real top-layer overlay, a free backdrop via ::backdrop, and Escape-to-close for nothing. Tailwind v4 drives the entrance through the starting: variant, which compiles to a @starting-style block: you write the open state as normal classes (opacity-100 scale-100) and the pre-open state as starting:opacity-0 starting:scale-95, the value the browser reads the instant the element enters the top layer. A transition utility bridges the two. The common trap is the exit: @starting-style only fires on the way in, so a plain close snaps the dialog shut with no fade. You need transition-discrete (which emits transition-behavior: allow-discrete) so the browser keeps the element painted while display and the top-layer membership animate out. That single utility is the difference between a smooth close and a flicker.
How does the age gate and size guide work with zero JavaScript — is :has() really enough?
Yes, and the trick is moving the state up the tree instead of onto the toggled element. The age gate (demo 03) and the cm/inch size toggle (demo 08) both hang a hidden checkbox or radio near the top of the component, then let an ancestor react with has-checked: — Tailwind's compile target for :has(:checked). So the wrapper says has-checked:pointer-events-none has-checked:opacity-0 and the whole gate dismisses when the box flips, no handler required. The unit toggle swaps two columns by pairing peer/has-checked: with hidden so only one measurement set is in flow at a time. Two things to watch: the input must be a real focusable control with a paired <label> so keyboard users can toggle it, and :has() has no old-browser fallback — a visitor on an engine without it sees the gate stuck open. For a hard legal gate that is a reason to keep a server check as well; for a UI convenience it is fine.
My page scrolls behind the modal when it's open — how do I lock the background?
This is the most common modal bug and the native <dialog> only half-solves it. showModal() makes the rest of the document inert, so clicks and Tab are blocked behind the overlay, but it does not stop wheel and touch scrolling of the underlying <body> — the page still moves under the dim backdrop. The drawers and slide-overs here (demos 06 and 15) lock it by toggling overflow-hidden on the root element when the dialog opens and removing it on close. On iOS overflow-hidden alone can still rubber-band; if you see that, also set overscroll-contain on the scrollable panel inside the modal so the scroll chain stops at the panel edge. Avoid position: fixed body hacks — they reset scroll position on close and cause a visible jump. And restore the class in the dialog's close event, not the click handler, so Escape and backdrop dismissal both unlock the page instead of leaving it frozen.
What accessibility do I still have to add — does native dialog handle focus and ARIA for me?
Native <dialog> opened with showModal() gives you a lot: it exposes an implicit role="dialog" with aria-modal, traps Tab inside the open dialog, makes the rest of the page inert, and closes on Escape. What it does not do is label itself or move focus intelligently. Point aria-labelledby at the heading so screen readers announce what opened, and set initial focus on the first meaningful control — the auth modal (demo 01) focuses the email field, the destructive delete (demo 11) focuses the type-to-confirm input, not the dangerous button. On close, return focus to the trigger that opened it, or the user is dumped at the top of the document. For the zero-JS :target lightbox and :has() gates you get none of the native trapping, so those need a real close control in the tab order and a visible focus-visible: ring. Icon-only close buttons always need an aria-label; a bare × is silent.
How does the ⌘K command palette move the selection with arrows but no state variable?
The neat part of demo 18 is that the highlighted row is derived, not stored. Each result is a real button in a list, and the palette uses roving selection: arrow keys move focus with focus() and the active look comes from focus-visible: plus sibling-aware variants, so the browser's own focus is the selection — there is no selectedIndex to keep in sync. Enter runs whatever is focused. Filtering as you type is the only piece that touches JS: it hides non-matching rows with a hidden class rather than removing them, which keeps the DOM order stable so arrow-walking never jumps. Opening on ⌘K means a keydown listener that checks event.metaKey (and ctrlKey for Windows) and calls preventDefault() — browsers bind ⌘K to the address bar, so skip that and the palette never opens. Remember to unbind or guard the listener when the dialog is closed so the shortcut does not fight other views.
When should I use the :target lightbox instead of a JavaScript one?
The :target gallery (demo 09) is genuinely zero-JS: each full-screen frame is an element whose id becomes the URL hash when you click its link, and target: variants show the matching panel while hiding the rest. It is perfect when you want the viewer to be linkable and survive with scripting disabled — a shared URL opens straight to that image. The trade-offs are real, though. Every open and close writes to the browser history, so the back button steps through each frame instead of leaving the page, which surprises people. It also fights other in-page anchors, since any #id navigation now competes for the same mechanism, and it cannot trap focus or mark the background inert the way a native <dialog> does. Use :target for a lightweight, deep-linkable image viewer; reach for the <dialog>-based modals when you need focus management, scroll lock, or a close that does not pollute history.
How do the tabbed settings and multi-step onboarding switch panels without a tab controller?
Two different zero-controller patterns. The settings modal (demo 17) uses radio tabs: one hidden radio group, a <label> for each tab, and the panels shown or hidden with peer/has-checked: variants. Because only one radio in a group can be checked, exactly one panel is ever visible — the browser enforces the mutual exclusion for free, and Tab plus arrow keys already navigate a radio group, so keyboard support comes with it. The onboarding tour (demo 14) is not pure CSS — it keys off a data-step attribute on the dialog and Tailwind's data-[step=2]: style variants, with tiny JS that only increments the attribute on Next. That is deliberate: a three-way step needs a value that can move forward, back, and skip, which radios model awkwardly. The mistake to avoid is styling the panels with hidden only via opacity — a display:none-equivalent (hidden) must win, or a screen reader reads all three panels at once.
Is the native dialog element safe to ship, and when should I not reach for a modal at all?
The native <dialog> with showModal(), ::backdrop, and top-layer rendering is well supported across current engines, and the @starting-style and transition-discrete animation utilities used here are the newer piece — they enhance the entrance but degrade to an instant open on an engine that lacks them, so nothing breaks. The bigger question is whether a modal is the right control. Do not trap something the user needs to reference against the page behind it, and do not modal-gate content a search engine or a linked visitor should reach — the paywall (demo 04) and age gate (demo 03) are intentional friction, but the same pattern applied to ordinary content just hides it. Prefer the slide-over drawer (demo 06) or the filter sheet (demo 15) when the task is a companion to the page rather than a hard interrupt, and inline the GDPR consent as a corner sheet (demo 13), not a blocking overlay, so it never stops a first-time visitor from reading anything.