Tailwind Modal: 16 Examples Built on the Dialog Element

Most of a Tailwind modal is the native dialog element doing its own job. One showModal() call gives you the backdrop, the Escape key and an inert page behind it, and Tailwind only has to style what the browser already built.

Below are 16 Tailwind modal examples you can try and copy, followed by a guide to building, closing, animating and testing them in Tailwind v4.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Confirmations and alert dialogs

Short dialogs that ask for one decision. Each one is a native dialog opened with showModal(), so the browser supplies the backdrop, the Escape key, the inert page and the focus return, and Tailwind styles the panel and the ::backdrop.

Type-to-confirm delete

  • showModal()
  • backdrop:
  • open:
  • starting:
  • disabled:

Click the button to open the dialog.

The panel fades and scales in through open: and starting:open: classes inside motion-safe:, and the ::backdrop does the same with open:backdrop: and transition-discrete so it also fades out. The delete button stays disabled until the file name is typed, and disabled: classes show that state. The JS opens the dialog, compares the input and passes the choice to close() as the returnValue.

View code
<div class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
  <div class="flex items-center gap-3">
    <span class="grid size-11 shrink-0 place-items-center rounded-xl bg-amber-100 text-amber-700">
      <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 3h8l4 4v14H6zM14 3v4h4M11 3v2m0 2v2m0 2v2"/><rect x="9.5" y="14" width="3" height="4" rx="1"/></svg>
    </span>
    <div class="min-w-0 flex-1">
      <p id="tmd-del-name" class="truncate font-medium text-slate-900">brand-assets-q3.zip</p>
      <p id="tmd-del-status" class="text-sm text-slate-500" role="status">248 MB, shared with 6 people</p>
    </div>
    <button type="button" id="tmd-del-open" aria-haspopup="dialog" aria-label="Delete brand-assets-q3.zip" class="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-slate-500 hover:bg-red-50 hover:text-red-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:pointer-events-none disabled:opacity-40">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7h16M10 11v6m4-6v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
    </button>
  </div>
</div>
<dialog id="tmd-del" aria-labelledby="tmd-del-title" aria-describedby="tmd-del-desc" class="m-auto w-[calc(100%-2rem)] max-w-md overflow-y-auto rounded-2xl bg-white p-0 text-slate-900 shadow-2xl opacity-0 scale-95 open:opacity-100 open:scale-100 motion-safe:transition-[opacity,scale,display,overlay] motion-safe:transition-discrete motion-safe:duration-200 motion-safe:ease-out motion-safe:starting:open:opacity-0 motion-safe:starting:open:scale-95 backdrop:bg-slate-950/0 open:backdrop:bg-slate-950/50 motion-safe:backdrop:transition-[background-color,display,overlay] motion-safe:backdrop:transition-discrete motion-safe:backdrop:duration-200 motion-safe:starting:open:backdrop:bg-slate-950/0">
  <div class="p-6">
    <div class="flex gap-4">
      <span class="grid size-11 shrink-0 place-items-center rounded-full bg-red-100 text-red-600">
        <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 3.5v.01M10.3 3.9 2.6 17.3A2 2 0 0 0 4.3 20.3h15.4a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/></svg>
      </span>
      <div class="min-w-0">
        <h2 id="tmd-del-title" class="text-lg font-semibold text-balance">Delete brand-assets-q3.zip?</h2>
        <p id="tmd-del-desc" class="mt-1 text-sm text-slate-600">The file and its 6 share links are removed for everyone. This cannot be undone.</p>
      </div>
    </div>
    <label for="tmd-del-input" class="mt-5 block text-sm text-slate-700">Type <span class="font-mono font-semibold text-slate-900">brand-assets</span> to confirm</label>
    <input id="tmd-del-input" type="text" autocomplete="off" spellcheck="false" class="mt-2 block min-h-11 w-full rounded-lg border border-slate-300 px-3 font-mono text-sm text-slate-900 focus:border-red-500 focus:outline-3 focus:outline-red-500/25">
  </div>
  <div class="flex flex-col-reverse gap-2 bg-slate-50 px-6 py-4 sm:flex-row sm:justify-end">
    <button type="button" id="tmd-del-cancel" class="min-h-11 rounded-lg border border-slate-300 bg-white px-4 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-500">Cancel</button>
    <button type="button" id="tmd-del-confirm" disabled class="min-h-11 rounded-lg bg-red-600 px-4 text-sm font-semibold text-white hover:bg-red-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:cursor-not-allowed disabled:bg-red-200 disabled:text-red-900/60">Delete file</button>
  </div>
</dialog>

Session timeout alert dialog

  • role="alertdialog"
  • aria-describedby
  • returnValue
  • backdrop:backdrop-blur-xs

Click the button to open the dialog.

A native dialog can carry role="alertdialog", which tells screen readers the message is urgent and reads the aria-describedby text with the title. Stay signed in is the first focusable element, so showModal() puts focus there, and the backdrop gets backdrop:bg-stone-900/40 plus backdrop:backdrop-blur-xs. The JS runs a countdown with chained setTimeout calls that stop when the dialog closes.

View code
<div class="w-full max-w-sm rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
  <p class="text-sm font-medium text-stone-900">Quarterly report, draft 3</p>
  <p id="tmd-idle-status" class="mt-1 text-sm text-stone-500" role="status">Last saved 2 minutes ago</p>
  <button type="button" id="tmd-idle-open" aria-haspopup="dialog" class="mt-4 inline-flex min-h-11 items-center rounded-lg bg-stone-900 px-4 text-sm font-semibold text-white hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900">Simulate 28 idle minutes</button>
</div>
<dialog id="tmd-idle" role="alertdialog" aria-labelledby="tmd-idle-title" aria-describedby="tmd-idle-desc" class="m-auto w-[calc(100%-2rem)] max-w-sm overflow-y-auto rounded-2xl border-t-4 border-amber-400 bg-white p-6 text-center text-stone-900 shadow-xl backdrop:bg-stone-900/40 backdrop:backdrop-blur-xs motion-safe:open:animate-[tmd-pop_.22s_ease-out]">
  <span class="mx-auto grid size-14 place-items-center rounded-full bg-amber-100 text-amber-700">
    <svg class="size-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path stroke-linecap="round" d="M12 9v4l2.5 2M9 2h6"/></svg>
  </span>
  <h2 id="tmd-idle-title" class="mt-4 text-lg font-semibold">Your session is about to end</h2>
  <p id="tmd-idle-desc" class="mt-2 text-sm text-stone-600">For your security you will be signed out in <span id="tmd-idle-time" class="font-semibold tabular-nums text-stone-900">2:00</span>. Your draft stays saved.</p>
  <div class="mt-6 grid gap-2">
    <button type="button" id="tmd-idle-stay" class="min-h-11 rounded-lg bg-amber-400 px-4 text-sm font-semibold text-stone-950 hover:bg-amber-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600">Stay signed in</button>
    <button type="button" id="tmd-idle-out" class="min-h-11 rounded-lg px-4 text-sm font-semibold text-stone-700 hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-500">Sign out now</button>
  </div>
</dialog>

Nested discard confirmation

  • cancel event
  • top layer
  • backdrop:
  • field-sizing-content

Open the editor, change the text, then press Cancel or Escape.

Two dialogs stacked in the top layer: the editor, and a smaller confirm that opens above it when you leave with unsaved text. The cancel event catches Escape, so the same check runs for the keyboard and the buttons. The textarea grows with field-sizing-content and min-h-32, and each dialog gets its own ::backdrop.

View code
<article class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-zinc-200">
  <div class="flex items-start justify-between gap-4">
    <div class="min-w-0">
      <h3 class="font-semibold text-zinc-900">Release notes, v2.4</h3>
      <p id="tmd-nest-text" class="mt-2 text-sm text-zinc-600">Faster exports, a new color picker and fixes for the timeline on tablets.</p>
    </div>
    <button type="button" id="tmd-nest-open" aria-haspopup="dialog" class="inline-flex min-h-11 shrink-0 items-center rounded-lg border border-zinc-300 px-3.5 text-sm font-semibold text-zinc-800 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600">Edit</button>
  </div>
</article>
<dialog id="tmd-nest" aria-labelledby="tmd-nest-title" class="m-auto w-[calc(100%-2rem)] max-w-lg overflow-y-auto rounded-2xl bg-white p-0 text-zinc-900 shadow-2xl backdrop:bg-zinc-900/50 motion-safe:open:animate-[tmd-rise_.2s_ease-out]">
  <div class="flex items-center justify-between border-b border-zinc-200 px-5 py-3">
    <h2 id="tmd-nest-title" class="font-semibold">Edit release notes</h2>
    <button type="button" id="tmd-nest-x" aria-label="Close" class="grid size-11 place-items-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-violet-600">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
    </button>
  </div>
  <div class="p-5">
    <label for="tmd-nest-area" class="text-sm font-medium text-zinc-700">Summary</label>
    <textarea id="tmd-nest-area" rows="4" class="mt-2 block min-h-32 w-full resize-none rounded-lg border border-zinc-300 p-3 text-sm leading-6 text-zinc-900 field-sizing-content focus:border-violet-500 focus:outline-3 focus:outline-violet-500/25"></textarea>
  </div>
  <div class="flex justify-end gap-2 px-5 pb-5">
    <button type="button" id="tmd-nest-cancel" class="min-h-11 rounded-lg px-4 text-sm font-semibold text-zinc-700 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500">Cancel</button>
    <button type="button" id="tmd-nest-save" class="min-h-11 rounded-lg bg-violet-700 px-4 text-sm font-semibold text-white hover:bg-violet-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700">Save notes</button>
  </div>
</dialog>
<dialog id="tmd-nest-confirm" aria-labelledby="tmd-nest-c-title" aria-describedby="tmd-nest-c-desc" class="m-auto w-[calc(100%-3rem)] max-w-xs rounded-xl bg-white p-5 text-zinc-900 shadow-2xl ring-1 ring-zinc-900/5 backdrop:bg-zinc-900/30 motion-safe:open:animate-[tmd-rise_.16s_ease-out]">
  <h2 id="tmd-nest-c-title" class="font-semibold">Discard your changes?</h2>
  <p id="tmd-nest-c-desc" class="mt-1 text-sm text-zinc-600">The edits you made to the summary will be lost.</p>
  <div class="mt-5 grid grid-cols-2 gap-2">
    <button type="button" id="tmd-nest-keep" class="min-h-11 rounded-lg border border-zinc-300 text-sm font-semibold text-zinc-800 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600">Keep editing</button>
    <button type="button" id="tmd-nest-discard" class="min-h-11 rounded-lg bg-zinc-900 text-sm font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900">Discard</button>
  </div>
</dialog>

Send invoice with success state

  • group-data-[state=sent]:
  • animate-spin
  • motion-reduce:
  • accent-*

Open it and press Send invoice.

One data-state attribute on the dialog drives every view: group-data-[state=sending]: shows the spinner and dims the button, group-data-[state=sent]: swaps the form for the confirmation. The JS only changes the attribute, waits for a fake request and moves focus to the success heading so screen readers hear the new state.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
  <div class="flex items-center justify-between gap-3">
    <div>
      <p class="text-sm font-medium text-slate-500">INV-2048</p>
      <p class="mt-0.5 font-semibold text-slate-900">Northwind Studio</p>
    </div>
    <p class="text-right"><span class="block text-lg font-semibold tabular-nums text-slate-900">$4,860.00</span><span id="tmd-inv-badge" class="inline-block rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">Draft</span></p>
  </div>
  <button type="button" id="tmd-inv-open" aria-haspopup="dialog" class="mt-4 inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Send invoice</button>
</div>
<dialog id="tmd-inv" data-state="form" aria-labelledby="tmd-inv-title" class="group m-auto w-[calc(100%-2rem)] max-w-md overflow-y-auto rounded-2xl bg-white p-0 text-slate-900 shadow-2xl backdrop:bg-indigo-950/40 motion-safe:open:animate-[tmd-pop_.22s_ease-out]">
  <form id="tmd-inv-form" class="p-6 group-data-[state=sent]:hidden">
    <h2 id="tmd-inv-title" class="text-lg font-semibold">Send INV-2048</h2>
    <p class="mt-1 text-sm text-slate-600">$4,860.00 due November 15, 2026</p>
    <label for="tmd-inv-to" class="mt-5 block text-sm font-medium text-slate-700">To</label>
    <input id="tmd-inv-to" type="email" required value="accounts@example.com" class="mt-1.5 block min-h-11 w-full rounded-lg border border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:outline-3 focus:outline-indigo-500/25">
    <label for="tmd-inv-msg" class="mt-4 block text-sm font-medium text-slate-700">Message</label>
    <textarea id="tmd-inv-msg" rows="3" class="mt-1.5 block w-full resize-none rounded-lg border border-slate-300 p-3 text-sm text-slate-900 focus:border-indigo-500 focus:outline-3 focus:outline-indigo-500/25">Hi Dana, here is the invoice for the October sprint. Thanks again!</textarea>
    <label class="mt-4 flex min-h-11 items-center gap-3 text-sm text-slate-700"><input type="checkbox" checked class="size-4 accent-indigo-600"> Attach a PDF copy</label>
    <div class="mt-4 flex justify-end gap-2">
      <button type="button" id="tmd-inv-cancel" class="min-h-11 rounded-lg px-4 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-500">Cancel</button>
      <button type="submit" id="tmd-inv-send" class="inline-flex min-h-11 items-center gap-2 rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 group-data-[state=sending]:pointer-events-none group-data-[state=sending]:opacity-70">
        <svg class="hidden size-4 animate-spin group-data-[state=sending]:block motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
        <span class="group-data-[state=sending]:hidden">Send invoice</span><span class="hidden group-data-[state=sending]:inline">Sending</span>
      </button>
    </div>
  </form>
  <div class="hidden p-8 text-center group-data-[state=sent]:block">
    <span class="mx-auto grid size-16 place-items-center rounded-full bg-emerald-100 text-emerald-700">
      <svg class="size-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 12.5 4.5 4.5L19 7.5"/></svg>
    </span>
    <h2 id="tmd-inv-done" tabindex="-1" class="mt-4 text-lg font-semibold focus:outline-none">Invoice sent</h2>
    <p class="mt-1 text-sm text-slate-600">Northwind Studio gets INV-2048 at <span id="tmd-inv-email" class="font-medium text-slate-900">accounts@example.com</span>. We will tell you when it is viewed.</p>
    <button type="button" id="tmd-inv-close" class="mt-6 min-h-11 w-full rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Done</button>
  </div>
</dialog>

Forms, wizards and command palettes

Dialogs that collect input: a sign-in form, a profile editor, a three-step wizard and a searchable command palette. The forms use method="dialog", so the clicked button becomes the dialog returnValue.

Sign-in form with method dialog

  • method="dialog"
  • formnovalidate
  • returnValue
  • accent-*
  • aria-pressed

Open it, fill in the form and try the eye button.

The form uses method="dialog", so the Sign in and close buttons both end the dialog and their value becomes the returnValue. The close button has formnovalidate so empty required fields cannot trap the user. The submit listener reads event.submitter and calls close() itself, which also leaves room to check the password with a server first.

View code
<div class="flex w-full max-w-md items-center justify-between gap-3 rounded-2xl bg-white px-5 py-3 shadow-sm ring-1 ring-stone-200">
  <p class="flex items-center gap-2 font-semibold text-stone-900"><svg class="size-6 text-emerald-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 19c4-1 7-4 9-9l3-6M5 19c-1-6 2-11 9-12M5 19l4-4"/></svg>Fieldnotes</p>
  <div id="tmd-in-slot" class="flex items-center gap-2">
    <button type="button" id="tmd-in-open" aria-haspopup="dialog" class="inline-flex min-h-11 items-center rounded-full bg-emerald-700 px-5 text-sm font-semibold text-white hover:bg-emerald-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Sign in</button>
  </div>
</div>
<dialog id="tmd-in" aria-labelledby="tmd-in-title" class="m-auto w-[calc(100%-2rem)] max-w-sm overflow-y-auto rounded-3xl bg-stone-50 p-0 text-stone-900 shadow-2xl opacity-0 translate-y-4 open:opacity-100 open:translate-y-0 motion-safe:transition-[opacity,translate,display,overlay] motion-safe:transition-discrete motion-safe:duration-300 motion-safe:ease-out motion-safe:starting:open:opacity-0 motion-safe:starting:open:translate-y-4 backdrop:bg-emerald-950/50 backdrop:backdrop-blur-sm">
  <form method="dialog" id="tmd-in-form" class="relative p-7">
    <button value="cancel" formnovalidate aria-label="Close" class="absolute top-3 right-3 grid size-11 place-items-center rounded-full text-stone-500 hover:bg-stone-200 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-emerald-700">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
    </button>
    <h2 id="tmd-in-title" class="text-2xl font-semibold tracking-tight">Welcome back</h2>
    <p class="mt-1 text-sm text-stone-600">Sign in to sync your notes and maps.</p>
    <label for="tmd-in-email" class="mt-6 block text-sm font-medium text-stone-700">Email</label>
    <input id="tmd-in-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="mt-1.5 block min-h-11 w-full rounded-xl border border-stone-300 bg-white px-3.5 text-sm text-stone-900 placeholder:text-stone-500 user-invalid:border-red-600 focus:border-emerald-600 focus:outline-3 focus:outline-emerald-600/20">
    <div class="mt-4 flex items-baseline justify-between">
      <label for="tmd-in-pass" class="text-sm font-medium text-stone-700">Password</label>
      <a href="#" class="text-sm font-medium text-emerald-800 underline-offset-2 hover:underline">Forgot it?</a>
    </div>
    <div class="relative mt-1.5">
      <input id="tmd-in-pass" name="password" type="password" required minlength="8" autocomplete="current-password" class="block min-h-11 w-full rounded-xl border border-stone-300 bg-white py-2 pr-12 pl-3.5 text-sm text-stone-900 focus:border-emerald-600 focus:outline-3 focus:outline-emerald-600/20">
      <button type="button" id="tmd-in-eye" aria-pressed="false" aria-label="Show password" class="group/eye absolute inset-y-0 right-0 grid w-11 place-items-center rounded-r-xl text-stone-500 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-emerald-700">
        <svg class="size-5 group-aria-pressed/eye:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M2.5 12S6 5 12 5s9.5 7 9.5 7-3.5 7-9.5 7-9.5-7-9.5-7Z"/><circle cx="12" cy="12" r="3"/></svg>
        <svg class="hidden size-5 group-aria-pressed/eye:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" d="M3 3l18 18M10.6 5.1A9.7 9.7 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-3 3.8M6.6 6.6A16.6 16.6 0 0 0 2.5 12S6 19 12 19a9.4 9.4 0 0 0 4.4-1.1M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>
      </button>
    </div>
    <label class="mt-4 flex min-h-11 items-center gap-3 text-sm text-stone-700"><input type="checkbox" name="remember" class="size-4 accent-emerald-700"> Keep me signed in on this device</label>
    <button value="signin" class="mt-3 min-h-12 w-full rounded-xl bg-emerald-700 text-sm font-semibold text-white hover:bg-emerald-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Sign in</button>
    <p class="mt-4 text-center text-sm text-stone-600">New here? <a href="#" class="font-medium text-emerald-800 underline underline-offset-2">Create an account</a></p>
  </form>
</dialog>

Edit profile form

  • method="dialog"
  • peer-checked:
  • max-h-[calc(100dvh-2rem)]
  • overscroll-contain

Open it, edit the fields and press Save.

A longer form that caps the dialog at max-h-[calc(100dvh-2rem)] and scrolls inside it with overscroll-contain, so the page behind never moves. The visibility switch is a real checkbox styled through peer-checked: classes. On close, a returnValue of save copies the fields back into the profile card and rebuilds the initials.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
  <div class="flex items-center gap-4">
    <svg class="size-14 shrink-0" viewBox="0 0 56 56" aria-hidden="true"><circle cx="28" cy="28" r="28" fill="#e0f2fe"/><text id="tmd-prof-ini" x="28" y="34" text-anchor="middle" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="#075985">AK</text></svg>
    <div class="min-w-0">
      <p id="tmd-prof-name-out" class="truncate font-semibold text-slate-900">Ana Kovac</p>
      <p id="tmd-prof-role-out" class="truncate text-sm text-slate-500">Product designer at Lumen Works</p>
    </div>
  </div>
  <p id="tmd-prof-bio-out" class="mt-4 text-sm text-slate-600">Designs onboarding flows and writes about color systems. Based in Ljubljana.</p>
  <button type="button" id="tmd-prof-open" aria-haspopup="dialog" class="mt-4 inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-slate-300 text-sm font-semibold text-slate-800 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600">Edit profile</button>
</div>
<dialog id="tmd-prof" aria-labelledby="tmd-prof-title" class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg overflow-y-auto overscroll-contain rounded-2xl bg-white p-0 text-slate-900 shadow-2xl backdrop:bg-slate-900/60 motion-safe:open:animate-[tmd-rise_.2s_ease-out]">
  <form method="dialog" id="tmd-prof-form">
    <div class="border-b border-slate-200 px-6 py-4">
      <h2 id="tmd-prof-title" class="text-lg font-semibold">Edit profile</h2>
      <p class="text-sm text-slate-500">This is what teammates see on your card.</p>
    </div>
    <div class="grid gap-4 px-6 py-5 sm:grid-cols-2">
      <div>
        <label for="tmd-prof-name" class="block text-sm font-medium text-slate-700">Full name</label>
        <input id="tmd-prof-name" name="name" required autocomplete="name" class="mt-1.5 block min-h-11 w-full rounded-lg border border-slate-300 px-3 text-sm text-slate-900 focus:border-sky-600 focus:outline-3 focus:outline-sky-600/20">
      </div>
      <div>
        <label for="tmd-prof-role" class="block text-sm font-medium text-slate-700">Job title</label>
        <input id="tmd-prof-role" name="role" autocomplete="organization-title" class="mt-1.5 block min-h-11 w-full rounded-lg border border-slate-300 px-3 text-sm text-slate-900 focus:border-sky-600 focus:outline-3 focus:outline-sky-600/20">
      </div>
      <div class="sm:col-span-2">
        <div class="flex items-baseline justify-between">
          <label for="tmd-prof-bio" class="block text-sm font-medium text-slate-700">Bio</label>
          <span id="tmd-prof-count" class="text-xs tabular-nums text-slate-500">0/160</span>
        </div>
        <textarea id="tmd-prof-bio" name="bio" rows="3" maxlength="160" aria-describedby="tmd-prof-count" class="mt-1.5 block w-full resize-none rounded-lg border border-slate-300 p-3 text-sm text-slate-900 focus:border-sky-600 focus:outline-3 focus:outline-sky-600/20"></textarea>
      </div>
      <label class="flex min-h-11 cursor-pointer items-center justify-between gap-4 rounded-lg bg-slate-50 px-4 py-3 sm:col-span-2">
        <span><span class="block text-sm font-medium text-slate-800">Show profile in the team directory</span><span class="block text-xs text-slate-500">People outside your workspace cannot see it.</span></span>
        <input type="checkbox" checked class="peer sr-only">
        <span class="relative h-6 w-11 shrink-0 rounded-full bg-slate-300 transition-colors peer-checked:bg-sky-600 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-sky-600 after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" aria-hidden="true"></span>
      </label>
    </div>
    <div class="flex justify-end gap-2 border-t border-slate-200 px-6 py-4">
      <button value="cancel" formnovalidate class="min-h-11 rounded-lg px-4 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-500">Cancel</button>
      <button value="save" class="min-h-11 rounded-lg bg-sky-700 px-5 text-sm font-semibold text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Save changes</button>
    </div>
  </form>
</dialog>

Three-step setup wizard

  • group-data-[step=2]:
  • aria-[current=step]:
  • has-checked:
  • invalid:

Open it and walk through the steps.

The dialog carries data-step, and each panel shows itself with a group-data-[step=n]:block class, so the JS never touches display. The step list styles the active item with aria-[current=step]: and finished ones with a data-done attribute. Plan cards are radio inputs inside labels that highlight with has-checked:, and Next refuses to leave step 2 while the name field is invalid.

View code
<div class="w-full max-w-sm rounded-2xl border-2 border-dashed border-neutral-300 bg-white p-6 text-center">
  <svg class="mx-auto size-10 text-orange-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path stroke-linecap="round" d="M12 9v6M9 12h6"/></svg>
  <p class="mt-3 font-semibold text-neutral-900">No workspace yet</p>
  <p id="tmd-wiz-status" class="mt-1 text-sm text-neutral-600" role="status">Create one to start inviting your team.</p>
  <button type="button" id="tmd-wiz-open" aria-haspopup="dialog" class="mt-4 inline-flex min-h-11 items-center rounded-lg bg-orange-700 px-5 text-sm font-semibold text-white hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700">New workspace</button>
</div>
<dialog id="tmd-wiz" data-step="1" aria-labelledby="tmd-wiz-title" class="group m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg overflow-y-auto rounded-2xl bg-white p-0 text-neutral-900 shadow-2xl backdrop:bg-neutral-950/50 motion-safe:open:animate-[tmd-pop_.22s_ease-out]">
  <div class="px-6 pt-6">
    <h2 id="tmd-wiz-title" class="text-lg font-semibold">Create a workspace</h2>
    <ol class="mt-4 grid grid-cols-3 gap-2 text-xs font-medium text-neutral-500">
      <li class="tmd-wiz-dot border-t-4 border-neutral-200 pt-2 aria-[current=step]:border-orange-600 aria-[current=step]:text-orange-800 data-done:border-orange-300" aria-current="step">Plan</li>
      <li class="tmd-wiz-dot border-t-4 border-neutral-200 pt-2 aria-[current=step]:border-orange-600 aria-[current=step]:text-orange-800 data-done:border-orange-300">Details</li>
      <li class="tmd-wiz-dot border-t-4 border-neutral-200 pt-2 aria-[current=step]:border-orange-600 aria-[current=step]:text-orange-800 data-done:border-orange-300">Invite</li>
    </ol>
  </div>
  <div class="px-6 py-5">
    <fieldset class="hidden group-data-[step=1]:block">
      <legend class="text-sm font-medium text-neutral-700">Choose a plan</legend>
      <div class="mt-3 grid gap-2">
        <label class="flex cursor-pointer items-center gap-3 rounded-xl border border-neutral-300 p-3.5 has-checked:border-orange-600 has-checked:bg-orange-50 has-focus-visible:outline-2 has-focus-visible:outline-orange-600"><input type="radio" name="tmd-wiz-plan" value="Starter" checked class="size-4 accent-orange-700"><span class="flex-1"><span class="block text-sm font-semibold">Starter</span><span class="block text-xs text-neutral-600">Up to 5 people, 10 GB</span></span><span class="text-sm font-semibold">Free</span></label>
        <label class="flex cursor-pointer items-center gap-3 rounded-xl border border-neutral-300 p-3.5 has-checked:border-orange-600 has-checked:bg-orange-50 has-focus-visible:outline-2 has-focus-visible:outline-orange-600"><input type="radio" name="tmd-wiz-plan" value="Studio" class="size-4 accent-orange-700"><span class="flex-1"><span class="block text-sm font-semibold">Studio</span><span class="block text-xs text-neutral-600">Up to 25 people, 1 TB</span></span><span class="text-sm font-semibold">$8/seat</span></label>
      </div>
    </fieldset>
    <div class="hidden group-data-[step=2]:block">
      <label for="tmd-wiz-name" class="block text-sm font-medium text-neutral-700">Workspace name</label>
      <input id="tmd-wiz-name" required minlength="3" placeholder="Harbor Design" class="peer mt-1.5 block min-h-11 w-full rounded-lg border border-neutral-300 px-3 text-sm text-neutral-900 placeholder:text-neutral-500 focus:border-orange-600 focus:outline-3 focus:outline-orange-600/20 data-touched:invalid:border-red-600">
      <p class="mt-1.5 hidden text-xs text-red-700 peer-[[data-touched]:invalid]:block">Use at least 3 characters.</p>
      <p class="mt-3 text-sm text-neutral-600">Your address: <span id="tmd-wiz-url" class="font-mono text-neutral-900">harbor-design</span>.example.app</p>
    </div>
    <div class="hidden group-data-[step=3]:block">
      <label for="tmd-wiz-invite" class="block text-sm font-medium text-neutral-700">Invite teammates</label>
      <textarea id="tmd-wiz-invite" rows="3" placeholder="lee@example.com, sam@example.com" class="mt-1.5 block w-full resize-none rounded-lg border border-neutral-300 p-3 text-sm text-neutral-900 placeholder:text-neutral-500 focus:border-orange-600 focus:outline-3 focus:outline-orange-600/20"></textarea>
      <p class="mt-2 text-xs text-neutral-600">Separate addresses with commas. You can skip this and invite people later.</p>
    </div>
  </div>
  <div class="flex items-center justify-between gap-2 border-t border-neutral-200 px-6 py-4">
    <button type="button" id="tmd-wiz-back" class="min-h-11 rounded-lg px-4 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500">Cancel</button>
    <button type="button" id="tmd-wiz-next" class="min-h-11 rounded-lg bg-orange-700 px-5 text-sm font-semibold text-white hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700">Next</button>
  </div>
</dialog>

Command palette

  • mt-[12dvh]
  • role="combobox"
  • aria-selected:
  • outline-hidden
  • aria-keyshortcuts

Click the bar or press Ctrl+K, then type and use the arrow keys.

A dialog pinned near the top with mx-auto mt-[12dvh] mb-auto instead of the usual m-auto. The input is a combobox that points at the active option through aria-activedescendant, and options style their highlighted state with aria-selected: classes. The JS filters by hiding options, handles the arrow keys and Enter, and opens the palette with Ctrl+K or Cmd+K.

View code
<div class="w-full max-w-md">
  <button type="button" id="tmd-cmd-open" aria-haspopup="dialog" aria-keyshortcuts="Control+K Meta+K" class="flex min-h-12 w-full items-center gap-3 rounded-xl border border-slate-300 bg-white px-4 text-left text-sm text-slate-500 shadow-sm hover:border-slate-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
    <svg class="size-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path stroke-linecap="round" d="m16 16 4 4"/></svg>
    <span class="flex-1">Search or run a command</span>
    <kbd class="rounded-md border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-sans text-xs text-slate-600">Ctrl K</kbd>
  </button>
  <p id="tmd-cmd-status" class="mt-3 text-center text-sm text-slate-600" role="status">Nothing run yet.</p>
</div>
<dialog id="tmd-cmd" aria-label="Command palette" class="mx-auto mt-[12dvh] mb-auto w-[calc(100%-2rem)] max-w-lg overflow-hidden rounded-xl bg-white p-0 text-slate-900 shadow-2xl ring-1 ring-slate-900/10 backdrop:bg-slate-900/30 motion-safe:open:animate-[tmd-drop_.16s_ease-out]">
  <div class="flex items-center gap-3 border-b border-slate-200 px-4">
    <svg class="size-5 shrink-0 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path stroke-linecap="round" d="m16 16 4 4"/></svg>
    <input id="tmd-cmd-input" type="text" role="combobox" aria-expanded="true" aria-controls="tmd-cmd-list" aria-autocomplete="list" aria-label="Search commands" autocomplete="off" spellcheck="false" placeholder="Type a command" class="h-14 min-w-0 flex-1 bg-transparent text-slate-900 placeholder:text-slate-500 outline-hidden">
    <kbd class="hidden rounded-md border border-slate-300 px-1.5 py-0.5 font-sans text-xs text-slate-600 sm:inline">Esc</kbd>
  </div>
  <ul id="tmd-cmd-list" role="listbox" aria-label="Commands" class="max-h-64 overflow-y-auto p-2">
    <li id="tmd-cmd-1" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M12 5v14M5 12h14"/></svg><span class="tmd-cmd-label flex-1">New project</span><span class="text-xs text-slate-500">Action</span></li>
    <li id="tmd-cmd-2" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path stroke-linecap="round" d="M3 20c.8-3.5 3.2-5 6-5s5.2 1.5 6 5M18 8v6M15 11h6"/></svg><span class="tmd-cmd-label flex-1">Invite teammate</span><span class="text-xs text-slate-500">Action</span></li>
    <li id="tmd-cmd-3" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0-4-4m4 4 4-4M4 17v3h16v-3"/></svg><span class="tmd-cmd-label flex-1">Export invoices as CSV</span><span class="text-xs text-slate-500">Action</span></li>
    <li id="tmd-cmd-4" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg><span class="tmd-cmd-label flex-1">Toggle dark theme</span><span class="text-xs text-slate-500">Setting</span></li>
    <li id="tmd-cmd-5" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/></svg><span class="tmd-cmd-label flex-1">Open billing</span><span class="text-xs text-slate-500">Page</span></li>
    <li id="tmd-cmd-6" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path stroke-linecap="round" d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2"/></svg><span class="tmd-cmd-label flex-1">Go to settings</span><span class="text-xs text-slate-500">Page</span></li>
    <li id="tmd-cmd-7" role="option" aria-selected="false" class="tmd-cmd-opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm text-slate-700 aria-selected:bg-blue-50 aria-selected:text-blue-800"><svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 4h10l4 4v12H5zM9 12h6m-6 4h4"/></svg><span class="tmd-cmd-label flex-1">Search the docs</span><span class="text-xs text-slate-500">Help</span></li>
  </ul>
  <p id="tmd-cmd-empty" hidden class="px-4 pb-8 pt-6 text-center text-sm text-slate-600">No commands match that search.</p>
</dialog>

Media and long content

Lightboxes, quick views, long legal text and a shortcut sheet. These show how far a dialog can stretch: arbitrary max widths and heights, sticky headers inside a scrolling panel, light dismiss with closedby and a modal opened with no JavaScript at all.

Image lightbox with previous and next

  • bg-transparent
  • backdrop:bg-black/85
  • max-h-[68dvh]
  • object-contain
  • aria-live

Click a photo, then use the arrows or the arrow keys.

The dialog itself is bg-transparent with no padding, so only the photo, its caption and the round buttons float over a near-black ::backdrop. The image keeps its ratio inside max-h-[68dvh] with object-contain. The JS opens the dialog at the clicked thumbnail, swaps src and alt on Previous and Next, supports the arrow keys and announces the counter through aria-live.

View code
<div class="w-full max-w-lg">
  <ul class="grid grid-cols-3 gap-2" aria-label="Trip photos">
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="0" aria-haspopup="dialog"><img src="fjord.webp" width="800" height="600" alt="Deep blue fjord between granite cliffs, seen from a rocky ledge" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="1" aria-haspopup="dialog"><img src="forest-waterfall.webp" width="800" height="600" alt="Waterfall dropping into a mossy gorge in a pine forest" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="2" aria-haspopup="dialog"><img src="granite-valley.webp" width="800" height="600" alt="Granite cliff above pine trees and a calm river" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="3" aria-haspopup="dialog"><img src="highland-road.webp" width="800" height="600" alt="Narrow road winding below green highland ridges" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="4" aria-haspopup="dialog"><img src="red-rock-canyon.webp" width="800" height="600" alt="Red sandstone rocks glowing at sunset under a cloudy sky" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
    <li><button type="button" class="tmd-lb-thumb block w-full overflow-hidden rounded-lg focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-sky-600" data-index="5" aria-haspopup="dialog"><img src="canal-houses.webp" width="800" height="600" alt="Canal lined with old gabled houses and moored boats" class="aspect-4/3 w-full object-cover transition-transform duration-300 hover:scale-105 motion-reduce:transition-none"></button></li>
  </ul>
</div>
<dialog id="tmd-lb" aria-label="Photo viewer" class="m-auto w-[calc(100%-1.5rem)] max-w-3xl overflow-visible bg-transparent p-0 text-white backdrop:bg-black/85 motion-safe:open:animate-[tmd-pop_.2s_ease-out]">
  <button type="button" id="tmd-lb-close" aria-label="Close viewer" class="absolute -top-2 right-0 grid size-11 -translate-y-full place-items-center rounded-full text-white/85 hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-white">
    <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
  </button>
  <figure class="relative">
    <img id="tmd-lb-img" src="fjord.webp" width="800" height="600" alt="" class="mx-auto max-h-[68dvh] w-auto rounded-lg object-contain">
    <button type="button" id="tmd-lb-prev" aria-label="Previous photo" class="absolute top-1/2 left-2 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-white">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m15 5-7 7 7 7"/></svg>
    </button>
    <button type="button" id="tmd-lb-next" aria-label="Next photo" class="absolute top-1/2 right-2 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75 focus-visible:outline-2 focus-visible:outline-white">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
    </button>
    <figcaption class="mt-3 flex items-start justify-between gap-4 px-1 text-sm">
      <span id="tmd-lb-cap" class="text-white/90"></span>
      <span id="tmd-lb-count" class="shrink-0 tabular-nums text-white/75" aria-live="polite">1 of 6</span>
    </figcaption>
  </figure>
</dialog>

Quick view with light dismiss

  • closedby="any"
  • md:grid-cols-2
  • backdrop:bg-rose-950/40
  • scroll-smooth

Open the quick view, then click outside it.

closedby="any" turns on light dismiss, so a click on the backdrop closes the dialog in browsers that support the attribute. Where it is missing, the JS adds a fallback that closes when the click target is the dialog itself, which works because the padding sits on an inner wrapper. The layout stacks on phones and splits into two columns with md:grid-cols-2.

View code
<article class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200">
  <img src="snow-camp.webp" width="800" height="600" alt="Yellow tents on a snowy ridge below jagged peaks" class="aspect-video w-full object-cover">
  <div class="p-4">
    <p class="font-semibold text-stone-900">Snowline Base Camp</p>
    <p class="mt-0.5 text-sm text-stone-600">Guided winter trek, 4 nights</p>
    <button type="button" id="tmd-qv-open" aria-haspopup="dialog" class="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-rose-700 text-sm font-semibold text-white hover:bg-rose-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700">Quick view</button>
  </div>
</article>
<dialog id="tmd-qv" closedby="any" aria-labelledby="tmd-qv-title" class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-3xl overflow-y-auto rounded-2xl bg-white p-0 text-stone-900 shadow-2xl backdrop:bg-rose-950/40 backdrop:backdrop-blur-xs motion-safe:open:animate-[tmd-pop_.22s_ease-out]">
  <div class="grid md:grid-cols-2">
    <img src="snow-camp.webp" width="800" height="600" alt="Camp tents in deep snow with a stone wall and mountain ridge" class="aspect-video h-full w-full object-cover md:aspect-auto">
    <div class="relative p-6">
      <button type="button" id="tmd-qv-close" aria-label="Close quick view" class="absolute top-2 right-2 grid size-11 place-items-center rounded-full text-stone-500 hover:bg-stone-100 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-rose-700">
        <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
      </button>
      <p class="text-xs font-medium text-rose-800">Winter, moderate</p>
      <h2 id="tmd-qv-title" class="mt-1 pr-10 text-xl font-semibold">Snowline Base Camp</h2>
      <p class="mt-1 flex items-center gap-1 text-sm text-stone-600"><svg class="size-4 text-amber-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9Z"/></svg>4.8, 126 reviews</p>
      <p class="mt-4 text-sm leading-6 text-stone-700">Four nights in heated dome tents at 2,100 m, with a guide, snowshoes and every meal. Short day hikes, one summit push if the weather allows.</p>
      <ul class="mt-4 flex flex-wrap gap-2 text-xs font-medium text-stone-700">
        <li class="rounded-full bg-stone-100 px-2.5 py-1">Max 8 people</li>
        <li class="rounded-full bg-stone-100 px-2.5 py-1">Gear included</li>
        <li class="rounded-full bg-stone-100 px-2.5 py-1">Free cancellation</li>
      </ul>
      <div class="mt-6 flex items-end justify-between gap-3">
        <p><span class="text-2xl font-semibold">$940</span> <span class="text-sm text-stone-600">per person</span></p>
        <a href="#" class="inline-flex min-h-11 items-center rounded-lg bg-rose-700 px-4 text-sm font-semibold text-white hover:bg-rose-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700">See dates</a>
      </div>
    </div>
  </div>
</dialog>

Scrollable terms with sticky footer

  • sticky top-0
  • sticky bottom-0
  • max-h-[min(34rem,calc(100dvh-2rem))]
  • disabled:

Open the terms and scroll to the end.

The dialog itself is the scroll container, capped with max-h-[min(34rem,calc(100dvh-2rem))] and overscroll-contain. Header and footer use sticky top-0 and sticky bottom-0 with a translucent white and backdrop-blur-sm, so the text slides under them. The JS enables Accept once the reader reaches the end and ticks the checkbox on the page when they accept.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
  <p class="font-semibold text-slate-900">Create your account</p>
  <label class="mt-3 flex items-start gap-3 text-sm text-slate-700">
    <input type="checkbox" id="tmd-terms-check" required class="mt-0.5 size-4 shrink-0 accent-teal-700">
    <span>I agree to the <button type="button" id="tmd-terms-open" aria-haspopup="dialog" class="font-medium text-teal-800 underline underline-offset-2 hover:text-teal-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700">Terms of Service</button></span>
  </label>
</div>
<dialog id="tmd-terms" aria-labelledby="tmd-terms-title" class="m-auto max-h-[min(34rem,calc(100dvh-2rem))] w-[calc(100%-2rem)] max-w-lg overflow-y-auto overscroll-contain rounded-2xl bg-white p-0 text-slate-900 shadow-2xl backdrop:bg-teal-950/50 motion-safe:open:animate-[tmd-rise_.2s_ease-out]">
  <header class="sticky top-0 border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur-sm">
    <h2 id="tmd-terms-title" class="text-lg font-semibold">Terms of Service</h2>
    <p class="text-sm text-slate-600">Updated October 1, 2026</p>
  </header>
  <div class="space-y-4 px-6 py-5 text-sm leading-6 text-slate-700">
    <h3 class="font-semibold text-slate-900">Your account</h3>
    <p>You are responsible for what happens under your account. Keep your password private and tell us right away if you think someone else has used it.</p>
    <h3 class="font-semibold text-slate-900">What you can upload</h3>
    <p>You keep the rights to your files. You give Brightfold a license to store, copy and show them only so the service can work, for example to make previews or sync them between your devices.</p>
    <h3 class="font-semibold text-slate-900">Plans and billing</h3>
    <p>Paid plans renew every month or year until you cancel. Cancelling stops the next renewal, and you keep access until the end of the period you paid for.</p>
    <h3 class="font-semibold text-slate-900">Fair use</h3>
    <p>Do not use the service to send spam, break the law, probe other systems or upload malware. We may pause accounts that put other customers at risk.</p>
    <h3 class="font-semibold text-slate-900">Changes to these terms</h3>
    <p>When we change something important we email you at least 30 days before it takes effect. If you keep using the service after that date, the new terms apply.</p>
    <h3 class="font-semibold text-slate-900">Ending the agreement</h3>
    <p>You can delete your account at any time from Settings. We keep backups for 30 days, then remove your files for good.</p>
  </div>
  <footer class="sticky bottom-0 flex flex-col gap-3 border-t border-slate-200 bg-white/90 px-6 py-4 backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between">
    <p id="tmd-terms-hint" class="text-xs text-slate-600">Scroll to the end to accept.</p>
    <div class="flex gap-2">
      <button type="button" id="tmd-terms-no" class="min-h-11 flex-1 rounded-lg px-4 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-500 sm:flex-none">Decline</button>
      <button type="button" id="tmd-terms-yes" disabled class="min-h-11 flex-1 rounded-lg bg-teal-700 px-5 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-500 sm:flex-none">Accept</button>
    </div>
  </footer>
</dialog>

Shortcut sheet with no JavaScript

  • command="show-modal"
  • commandfor
  • command="close"
  • grid-cols-[1fr_auto]

Click a trigger to open the dialog.

Invoker Commands open and close this modal with HTML alone: the trigger has command="show-modal" and commandfor pointing at the dialog id, and the close button uses command="close". The result is a real modal with the same focus handling and Escape key as showModal(). The shortcut rows use a grid-cols-[1fr_auto] arbitrary template and styled kbd elements.

View code
<button type="button" command="show-modal" commandfor="tmd-keys" class="inline-flex min-h-11 items-center gap-2 rounded-lg bg-zinc-900 px-4 text-sm font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900">
  <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2.5" y="6" width="19" height="12" rx="2"/><path stroke-linecap="round" d="M6 10h1m3 0h1m3 0h1m3 0h1M7 14h10"/></svg>
  Keyboard shortcuts
</button>
<dialog id="tmd-keys" aria-labelledby="tmd-keys-title" class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-xl overflow-y-auto rounded-2xl bg-zinc-900 p-0 text-zinc-100 shadow-2xl ring-1 ring-white/10 backdrop:bg-zinc-950/60 motion-safe:open:animate-[tmd-pop_.2s_ease-out]">
  <div class="flex items-center justify-between border-b border-white/10 px-6 py-4">
    <h2 id="tmd-keys-title" class="font-semibold">Keyboard shortcuts</h2>
    <button type="button" command="close" commandfor="tmd-keys" aria-label="Close" class="grid size-11 place-items-center rounded-lg text-zinc-400 hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-sky-400">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
    </button>
  </div>
  <div class="grid gap-x-10 gap-y-6 p-6 sm:grid-cols-2">
    <section>
      <h3 class="text-xs font-semibold text-sky-300">Editing</h3>
      <dl class="mt-3 grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-2.5 text-sm">
        <dt class="text-zinc-300">Bold</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Ctrl</kbd><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">B</kbd></dd>
        <dt class="text-zinc-300">Insert link</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Ctrl</kbd><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">K</kbd></dd>
        <dt class="text-zinc-300">Undo</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Ctrl</kbd><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Z</kbd></dd>
        <dt class="text-zinc-300">Duplicate line</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Alt</kbd><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">D</kbd></dd>
      </dl>
    </section>
    <section>
      <h3 class="text-xs font-semibold text-sky-300">Navigation</h3>
      <dl class="mt-3 grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-2.5 text-sm">
        <dt class="text-zinc-300">Command palette</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Ctrl</kbd><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">P</kbd></dd>
        <dt class="text-zinc-300">Next page</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">J</kbd></dd>
        <dt class="text-zinc-300">Previous page</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">K</kbd></dd>
        <dt class="text-zinc-300">Close panel</dt><dd class="flex gap-1"><kbd class="min-w-7 rounded-md border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-center font-sans text-xs shadow-[inset_0_-2px_0_var(--color-zinc-700)]">Esc</kbd></dd>
      </dl>
    </section>
  </div>
  <p class="border-t border-white/10 px-6 py-3 text-xs text-zinc-400">On a Mac, use Cmd in place of Ctrl.</p>
</dialog>

Side panels, sheets and non-modal dialogs

The same dialog element placed against an edge: a slide-over cart, a bottom sheet that turns into a centered card at the sm breakpoint, a non-modal cookie banner and a plan picker styled with the dark variant.

Slide-over cart panel

  • ml-auto h-dvh max-h-none
  • translate-x-full
  • starting:open:
  • closedby="any"

Open the cart and change a quantity.

A side panel is still a centered dialog until you override the defaults: ml-auto mr-0 pins it right, h-dvh max-h-none makes it full height and rounded-none drops the corners. It slides with translate-x-full and open:translate-x-0, and starting:open:translate-x-full plus transition-discrete runs the slide on open and close. The JS updates quantities and totals.

View code
<header class="flex w-full max-w-lg items-center justify-between rounded-2xl bg-white px-5 py-3 shadow-sm ring-1 ring-neutral-200">
  <p class="font-serif text-lg font-semibold text-neutral-900">Paperfold Prints</p>
  <button type="button" id="tmd-cart-open" aria-haspopup="dialog" class="inline-flex min-h-11 items-center gap-2 rounded-full bg-neutral-900 px-4 text-sm font-semibold text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900">
    <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14l-1.2 12H6.2zM9 8V6a3 3 0 0 1 6 0v2"/></svg>
    Cart <span id="tmd-cart-badge" class="rounded-full bg-white px-1.5 text-xs text-neutral-900">3</span>
  </button>
</header>
<dialog id="tmd-cart" closedby="any" aria-labelledby="tmd-cart-title" class="my-0 mr-0 ml-auto h-dvh max-h-none w-full max-w-sm rounded-none bg-white p-0 text-neutral-900 shadow-2xl translate-x-full open:translate-x-0 motion-safe:transition-[translate,display,overlay] motion-safe:transition-discrete motion-safe:duration-300 motion-safe:ease-out motion-safe:starting:open:translate-x-full backdrop:bg-neutral-900/0 open:backdrop:bg-neutral-900/40 motion-safe:backdrop:transition-[background-color,display,overlay] motion-safe:backdrop:transition-discrete motion-safe:backdrop:duration-300 motion-safe:starting:open:backdrop:bg-neutral-900/0">
  <div class="flex h-full flex-col">
    <div class="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
      <h2 id="tmd-cart-title" class="text-lg font-semibold">Your cart</h2>
      <button type="button" id="tmd-cart-close" aria-label="Close cart" class="grid size-11 place-items-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-neutral-900">
        <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
      </button>
    </div>
    <ul class="flex-1 divide-y divide-neutral-200 overflow-y-auto overscroll-contain px-5">
      <li class="tmd-cart-item flex gap-3 py-4" data-price="38">
        <img src="fjord.webp" width="800" height="600" alt="" class="h-14 w-18 shrink-0 rounded-md object-cover">
        <div class="min-w-0 flex-1">
          <p class="text-sm font-medium">Fjord at noon, A3</p>
          <p class="text-sm text-neutral-600">$38</p>
        </div>
        <div class="flex h-9 items-center rounded-full border border-neutral-300">
          <button type="button" class="tmd-cart-minus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One less Fjord at noon">&minus;</button>
          <span class="tmd-cart-qty w-5 text-center text-sm tabular-nums">1</span>
          <button type="button" class="tmd-cart-plus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One more Fjord at noon">+</button>
        </div>
      </li>
      <li class="tmd-cart-item flex gap-3 py-4" data-price="32">
        <img src="jellyfish.webp" width="800" height="600" alt="" class="h-14 w-18 shrink-0 rounded-md object-cover">
        <div class="min-w-0 flex-1">
          <p class="text-sm font-medium">Orange jellyfish, A4</p>
          <p class="text-sm text-neutral-600">$32</p>
        </div>
        <div class="flex h-9 items-center rounded-full border border-neutral-300">
          <button type="button" class="tmd-cart-minus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One less Orange jellyfish">&minus;</button>
          <span class="tmd-cart-qty w-5 text-center text-sm tabular-nums">1</span>
          <button type="button" class="tmd-cart-plus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One more Orange jellyfish">+</button>
        </div>
      </li>
      <li class="tmd-cart-item flex gap-3 py-4" data-price="44">
        <img src="city-skyline.webp" width="800" height="600" alt="" class="h-14 w-18 shrink-0 rounded-md object-cover">
        <div class="min-w-0 flex-1">
          <p class="text-sm font-medium">Skyline at dusk, A2</p>
          <p class="text-sm text-neutral-600">$44</p>
        </div>
        <div class="flex h-9 items-center rounded-full border border-neutral-300">
          <button type="button" class="tmd-cart-minus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One less Skyline at dusk">&minus;</button>
          <span class="tmd-cart-qty w-5 text-center text-sm tabular-nums">1</span>
          <button type="button" class="tmd-cart-plus grid size-9 place-items-center rounded-full text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-neutral-900" aria-label="One more Skyline at dusk">+</button>
        </div>
      </li>
    </ul>
    <div class="border-t border-neutral-200 px-5 py-4">
      <p class="flex justify-between text-base font-semibold"><span>Subtotal</span><span id="tmd-cart-total" class="tabular-nums" aria-live="polite">$114</span></p>
      <p class="mt-1 text-xs text-neutral-600">Shipping and taxes are added at checkout.</p>
      <a href="#" class="mt-4 flex min-h-12 items-center justify-center rounded-full bg-neutral-900 text-sm font-semibold text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900">Checkout</a>
    </div>
  </div>
</dialog>

Bottom sheet on phones, card on desktop

  • mt-auto mb-0
  • sm:m-auto
  • translate-y-full
  • @theme
  • closedby="any"

Open the share sheet; resize to see both layouts.

Unprefixed classes build the phone layout: mt-auto mb-0 w-full max-w-none rounded-t-3xl docks the dialog to the bottom edge and translate-y-full slides it up. From the sm breakpoint, sm:m-auto and sm:max-w-md turn the same element into a centered card that fades in instead. The moss colors are @theme tokens with a tmd prefix, and light dismiss uses closedby with the same click fallback.

View code
<article class="w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200">
  <img src="granite-valley.webp" width="800" height="600" alt="Granite wall rising over pine forest and a river beach" class="aspect-[16/9] w-full object-cover">
  <div class="flex items-center justify-between gap-3 p-4">
    <div>
      <p class="font-semibold text-tmd-moss-900">Granite Valley Loop</p>
      <p class="text-sm text-stone-600">11.4 km, 520 m gain</p>
    </div>
    <button type="button" id="tmd-sheet-open" aria-haspopup="dialog" class="inline-flex min-h-11 items-center gap-2 rounded-full bg-tmd-moss-700 px-4 text-sm font-semibold text-white hover:bg-tmd-moss-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tmd-moss-700">
      <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15V3m0 0-4 4m4-4 4 4M5 13v7h14v-7"/></svg>Share
    </button>
  </div>
  <p id="tmd-sheet-status" class="px-4 pb-4 text-sm text-stone-600" role="status"></p>
</article>
<dialog id="tmd-sheet" closedby="any" aria-labelledby="tmd-sheet-title" class="mx-0 mt-auto mb-0 w-full max-w-none rounded-t-3xl bg-tmd-moss-50 p-0 text-tmd-moss-900 shadow-2xl translate-y-full open:translate-y-0 sm:m-auto sm:w-[calc(100%-2rem)] sm:max-w-md sm:translate-y-0 sm:rounded-3xl sm:opacity-0 sm:open:opacity-100 motion-safe:transition-[translate,opacity,display,overlay] motion-safe:transition-discrete motion-safe:duration-300 motion-safe:ease-out motion-safe:starting:open:translate-y-full motion-safe:sm:starting:open:translate-y-0 motion-safe:sm:starting:open:opacity-0 backdrop:bg-black/40">
  <div class="px-6 pt-3 pb-6">
    <span class="mx-auto block h-1.5 w-10 rounded-full bg-tmd-moss-900/20 sm:hidden" aria-hidden="true"></span>
    <h2 id="tmd-sheet-title" class="mt-4 text-lg font-semibold sm:mt-2">Share this route</h2>
    <ul class="mt-4 grid grid-cols-4 gap-2 text-center text-xs font-medium">
      <li><button type="button" class="tmd-sheet-target flex w-full flex-col items-center gap-1.5 rounded-xl py-2 hover:bg-white focus-visible:outline-2 focus-visible:outline-tmd-moss-700" data-target="Messages"><span class="grid size-12 place-items-center rounded-full bg-white text-tmd-moss-700 shadow-sm"><svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linejoin="round" d="M4 5h16v11H9l-5 4z"/></svg></span>Messages</button></li>
      <li><button type="button" class="tmd-sheet-target flex w-full flex-col items-center gap-1.5 rounded-xl py-2 hover:bg-white focus-visible:outline-2 focus-visible:outline-tmd-moss-700" data-target="Email"><span class="grid size-12 place-items-center rounded-full bg-white text-tmd-moss-700 shadow-sm"><svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path stroke-linejoin="round" d="m3.5 6 8.5 7 8.5-7"/></svg></span>Email</button></li>
      <li><button type="button" class="tmd-sheet-target flex w-full flex-col items-center gap-1.5 rounded-xl py-2 hover:bg-white focus-visible:outline-2 focus-visible:outline-tmd-moss-700" data-target="GPX file"><span class="grid size-12 place-items-center rounded-full bg-white text-tmd-moss-700 shadow-sm"><svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 17l5-9 4 6 3-4 4 7"/></svg></span>GPX file</button></li>
      <li><button type="button" class="tmd-sheet-target flex w-full flex-col items-center gap-1.5 rounded-xl py-2 hover:bg-white focus-visible:outline-2 focus-visible:outline-tmd-moss-700" data-target="Offline map"><span class="grid size-12 place-items-center rounded-full bg-white text-tmd-moss-700 shadow-sm"><svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v11m0 0-4-4m4 4 4-4M5 19h14"/></svg></span>Offline</button></li>
    </ul>
    <label for="tmd-sheet-link" class="mt-5 block text-sm font-medium">Route link</label>
    <input id="tmd-sheet-link" type="text" readonly value="trails.example.com/r/granite-valley" class="mt-1.5 block min-h-11 w-full rounded-xl border border-tmd-moss-900/15 bg-white px-3 font-mono text-sm text-tmd-moss-900 focus:outline-2 focus:outline-tmd-moss-700">
    <button type="button" id="tmd-sheet-close" class="mt-4 min-h-11 w-full rounded-xl text-sm font-semibold text-tmd-moss-700 hover:bg-white focus-visible:outline-2 focus-visible:outline-tmd-moss-700">Cancel</button>
  </div>
</dialog>

Dark mode upgrade dialog

  • dark:
  • dark:backdrop:bg-black/70
  • has-checked:
  • ring-white/10

Open it and switch plans or billing.

Every color has a dark: twin, including the ::backdrop: backdrop:bg-zinc-900/40 turns into dark:backdrop:bg-black/70, because a light dim barely shows on a dark page. A native dialog stays in place in the DOM, so a .dark class on html or any ancestor reaches it. Plan cards are radio labels that light up with has-checked:, and the JS keeps the button label and prices in sync.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-white/10">
  <p class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Projects</p>
  <p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400"><span id="tmd-up-used">4 of 5</span> used on the Free plan</p>
  <div class="mt-3 h-2 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800"><div class="h-full w-4/5 rounded-full bg-emerald-500"></div></div>
  <button type="button" id="tmd-up-open" aria-haspopup="dialog" class="mt-4 inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-emerald-400 text-sm font-semibold text-zinc-950 hover:bg-emerald-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400">Upgrade</button>
</div>
<dialog id="tmd-up" aria-labelledby="tmd-up-title" class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md overflow-y-auto rounded-2xl bg-white p-0 text-zinc-900 shadow-2xl ring-1 ring-black/5 opacity-0 scale-95 open:opacity-100 open:scale-100 motion-safe:transition-[opacity,scale,display,overlay] motion-safe:transition-discrete motion-safe:duration-200 motion-safe:starting:open:opacity-0 motion-safe:starting:open:scale-95 backdrop:bg-zinc-900/40 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-white/10 dark:backdrop:bg-black/70">
  <div class="p-6">
    <div class="flex items-start justify-between gap-4">
      <div>
        <h2 id="tmd-up-title" class="text-lg font-semibold">Upgrade your workspace</h2>
        <p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Unlimited projects and version history on every paid plan.</p>
      </div>
      <button type="button" id="tmd-up-x" aria-label="Close" class="-mt-2 -mr-2 grid size-11 shrink-0 place-items-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-emerald-500 dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white">
        <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></svg>
      </button>
    </div>
    <fieldset class="mt-5 grid grid-cols-2 rounded-lg bg-zinc-100 p-1 text-sm font-medium dark:bg-zinc-800">
      <legend class="sr-only">Billing period</legend>
      <label class="flex min-h-10 cursor-pointer items-center justify-center rounded-md text-zinc-600 has-checked:bg-white has-checked:text-zinc-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-emerald-500 dark:text-zinc-400 dark:has-checked:bg-zinc-700 dark:has-checked:text-white"><input type="radio" name="tmd-up-bill" value="month" checked class="sr-only">Monthly</label>
      <label class="flex min-h-10 cursor-pointer items-center justify-center rounded-md text-zinc-600 has-checked:bg-white has-checked:text-zinc-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-emerald-500 dark:text-zinc-400 dark:has-checked:bg-zinc-700 dark:has-checked:text-white"><input type="radio" name="tmd-up-bill" value="year" class="sr-only">Yearly, save 20%</label>
    </fieldset>
    <fieldset class="mt-4 grid gap-3">
      <legend class="sr-only">Plan</legend>
      <label class="flex cursor-pointer items-start gap-3 rounded-xl p-4 ring-1 ring-zinc-200 has-checked:ring-2 has-checked:ring-emerald-500 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-emerald-500 dark:ring-white/10 dark:has-checked:bg-emerald-400/5 dark:has-checked:ring-emerald-400">
        <input type="radio" name="tmd-up-plan" value="Pro" data-month="12" checked class="mt-1 size-4 accent-emerald-500">
        <span class="flex-1"><span class="block font-semibold">Pro</span><span class="block text-sm text-zinc-600 dark:text-zinc-400">For freelancers. 1 seat, 100 GB.</span></span>
        <span class="text-right"><span class="tmd-up-price block text-lg font-semibold tabular-nums">$12</span><span class="tmd-up-per block text-xs text-zinc-600 dark:text-zinc-400">per month</span></span>
      </label>
      <label class="flex cursor-pointer items-start gap-3 rounded-xl p-4 ring-1 ring-zinc-200 has-checked:ring-2 has-checked:ring-emerald-500 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-emerald-500 dark:ring-white/10 dark:has-checked:bg-emerald-400/5 dark:has-checked:ring-emerald-400">
        <input type="radio" name="tmd-up-plan" value="Team" data-month="29" class="mt-1 size-4 accent-emerald-500">
        <span class="flex-1"><span class="block font-semibold">Team</span><span class="block text-sm text-zinc-600 dark:text-zinc-400">Up to 10 seats, shared libraries, SSO.</span></span>
        <span class="text-right"><span class="tmd-up-price block text-lg font-semibold tabular-nums">$29</span><span class="tmd-up-per block text-xs text-zinc-600 dark:text-zinc-400">per month</span></span>
      </label>
    </fieldset>
    <ul class="mt-5 space-y-2 text-sm text-zinc-700 dark:text-zinc-300">
      <li class="flex gap-2"><svg class="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 12.5 4.5 4.5L19 7.5"/></svg>Unlimited projects and exports</li>
      <li class="flex gap-2"><svg class="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 12.5 4.5 4.5L19 7.5"/></svg>90 days of version history</li>
    </ul>
    <button type="button" id="tmd-up-go" class="mt-6 min-h-12 w-full rounded-lg bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:bg-emerald-400 dark:text-zinc-950 dark:hover:bg-emerald-300 dark:focus-visible:outline-emerald-400">Upgrade to Pro for $12 a month</button>
  </div>
</dialog>

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.

LayerTypical classesJob
Backdropfixed inset-0 bg-black/50Dims the page and catches outside clicks
Containerfixed inset-0 z-50 flex items-center justify-center p-4Centers the panel in the viewport
Panelw-full max-w-md rounded-lg bg-white p-6 shadow-xlHolds 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.

MethodOpen and closeOutside clickScroll lock
Native dialogshowModal() and close()Your own code, or closedby="any"Manual
Headless UI Dialogopen prop and onCloseBuilt in, fires onCloseBuilt in
FlowbiteData attributes or Modal JS APIDynamic backdrop closes itBody gets overflow-hidden
daisyUINative dialog, popover, checkbox or anchormodal-backdrop formDialog method locks the background
Alpine.jsx-show on an open flag@click.outsidex-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

  1. Add a <dialog> with an id and an aria-labelledby value that points to its title.
  2. 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).
  3. Style the backdrop with backdrop:bg-black/50. The ::backdrop pseudo-element appears only when the dialog opens through showModal() (MDN).
  4. 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.
  5. 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

ApproachSetupBackdrop fade-out
CSS transitions@starting-style, plus display and overlay in the transition list with allow-discreteWorks
CSS keyframesdisplay set inside the keyframes, no starting styleNot 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).

ClassMax widthTypical modal
max-w-sm24rem (384px)Alert or confirmation
max-w-md28rem (448px)Short form, default panel
max-w-lg32rem (512px)Login or settings
max-w-xl36rem (576px)Multi-field form
max-w-2xl42rem (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 100
  • fixed 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 habitv4 behaviorFix
focus:outline-noneSets outline-style: none, so the focus indicator can vanish in forced colors modeoutline-hidden
focus:ring1px wide in currentColor, not 3px bluefocus:ring-3 focus:ring-blue-500
hidden attribute with a flex classDisplay classes no longer override the attributeRemove 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.