Modals & Toasts
Dialogs, popovers, drawers, toasts and tooltips built on native elements — with the focus, animation and layering rules that make them usable.
Overlays are where accessibility bugs are most expensive: a modal that does not trap focus, a toast nobody hears, a tooltip only a mouse can reach. Modern HTML solves most of it for you, and Tailwind has variants for the states those elements expose.
The native <dialog>#
Open it with showModal() and the browser handles focus, the backdrop, Escape and the top layer.
<div class="grid min-h-56 place-items-center p-6">
<button type="button" onclick="document.getElementById('demo-dialog').showModal()"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600">
Delete project…
</button>
<dialog id="demo-dialog" aria-labelledby="demo-title"
class="m-auto w-full max-w-sm rounded-xl bg-white p-6 text-gray-900 shadow-xl backdrop:bg-gray-900/50">
<h2 id="demo-title" class="text-base font-semibold">Delete this project?</h2>
<p class="mt-2 text-sm text-gray-600">The project and all of its deployments are removed. This cannot be undone.</p>
<form method="dialog" class="mt-6 flex justify-end gap-3">
<button value="cancel" autofocus class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50">Cancel</button>
<button value="delete" class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white hover:bg-red-500">Delete</button>
</form>
</dialog>
</div>Note two details in that markup. The buttons live in a <form method="dialog">, so pressing either closes the dialog with no JavaScript and sets dialog.returnValue to the button's value. And autofocus is on Cancel, not on Delete — initial focus should never land on the destructive action.
What the browser gives you#
Calling showModal() — not show(), not a <div> — is what buys all of this:
- Focus moves in to the first focusable element, or to whatever has
autofocus. - Focus is contained. Tab cycles inside the dialog; the rest of the page is inert and cannot be reached, clicked or read by a screen reader.
- Esc closes it, firing
canceland thenclose. - Focus returns to the element that opened it.
- It renders in the top layer, above every stacking context on the page.
- You get a
::backdropto style with thebackdrop:variant.
dialog.show() gives you none of it: no backdrop, no inert page, no Escape. Use it only for genuinely non-modal panels.
Styling the backdrop#
backdrop: targets the ::backdrop pseudo-element, which only exists while the dialog is modal.
<dialog class="m-auto max-w-lg rounded-xl bg-white p-0 shadow-2xl
backdrop:bg-gray-900/50 backdrop:backdrop-blur-xs">::backdrop does not inherit anything from the page, so set colours explicitly. backdrop-blur-xs is v4's name for what v3 called backdrop-blur-sm — the whole blur scale shifted by one step.
Entry and exit animation#
An element going from display: none to visible has nothing to transition from. @starting-style supplies that first frame, and transition-behavior: allow-discrete lets display and overlay participate so the element stays visible long enough to animate out.
<dialog class="m-auto w-full max-w-md rounded-xl bg-white p-6 opacity-0 transition-all transition-discrete duration-200
open:opacity-100 starting:open:opacity-0
backdrop:bg-gray-900/0 backdrop:transition-all backdrop:duration-200
open:backdrop:bg-gray-900/50 starting:open:backdrop:bg-gray-900/0">Read the states in order:
| Class | Meaning |
|---|---|
opacity-0 | Closed, and the state it animates to on exit |
open:opacity-100 | The visible state, via the open variant ([open], :popover-open, :open) |
starting:open:opacity-0 | The first frame when it opens, from @starting-style |
transition-all transition-discrete | Includes display and overlay so the exit is not instant |
Add movement by transitioning a transform alongside opacity — scale-95 open:scale-100 starting:open:scale-95. Wrap anything ambitious in motion-reduce:transition-none.
Closing behaviour#
Escape is free with showModal(). To ask "discard unsaved changes?" first, cancel the event:
dialog.addEventListener('cancel', (event) => {
if (form.dirty) event.preventDefault()
})Backdrop click is not free. The ::backdrop is not a separate element — a click on it targets the dialog itself, so compare the event target:
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close()
})For this to feel right, give the <dialog> no padding (p-0) and put the padding on an inner wrapper. Otherwise a click on the dialog's own padding — visually inside the panel — closes it.
<dialog class="m-auto w-full max-w-md rounded-xl bg-white p-0 shadow-xl backdrop:bg-gray-900/50">
<div class="p-6">…</div>
</dialog>Body scroll lock#
An open modal dialog does not reliably stop the page behind it from scrolling. Lock it from CSS, keyed off the dialog's own state:
<body class="has-[dialog[open]]:overflow-hidden">No JavaScript, no scroll position to save and restore. Add overscroll-contain to a scrollable region inside the dialog so reaching its end does not start scrolling the page behind it.
The Popover API#
Popovers are the lightweight sibling of <dialog>: top layer, light dismiss (click outside or Escape) and focus return, but no focus trap and no inert background. That makes them right for menus, disclosure panels and notification lists, and wrong for anything that demands a decision.
<div class="grid min-h-48 place-items-center p-6">
<button type="button" popovertarget="demo-popover"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-900 shadow-xs hover:bg-gray-50">
What's new
</button>
<div id="demo-popover" popover
class="m-auto w-72 rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 shadow-xl transition-all transition-discrete duration-150 opacity-0 open:opacity-100 starting:open:opacity-0">
<p class="font-semibold text-gray-900">Version 4.3</p>
<p class="mt-1 text-gray-600">Scrollbar utilities, <code class="rounded bg-gray-100 px-1">zoom-*</code>, and size containers.</p>
<button type="button" popovertarget="demo-popover" popovertargetaction="hide"
class="mt-3 rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">
Got it
</button>
</div>
</div>popovertargeton the trigger wires the two together; no JavaScript is involved.popovertargetaction="hide"gives you a close button for free.- The
open:variant covers:popover-open, so the same animation classes work here as on<dialog>. - A bare
popoverattribute meanspopover="auto"— light dismiss, and only one auto popover open at a time.popover="manual"opts out of both.
Positioning a popover next to its trigger is the missing piece: the popover is in the top layer, so it cannot be positioned with absolute relative to the button. CSS anchor positioning is the eventual answer; until support is broad, position it with JavaScript or accept a centred panel.
Drawers and slide-overs#
A drawer is a dialog pinned to one edge. Build it on <dialog> and you keep the focus trap and Escape handling.
<dialog id="drawer" aria-labelledby="drawer-title"
class="m-0 ml-auto h-dvh w-80 max-w-full translate-x-full bg-white p-6 shadow-2xl
transition-all transition-discrete duration-300 motion-reduce:transition-none
open:translate-x-0 starting:open:translate-x-full
backdrop:bg-gray-900/50">
<h2 id="drawer-title" class="text-base font-semibold text-gray-900">Filters</h2>
…
</dialog>m-0 ml-auto overrides the centring so the panel sits against the right edge; h-dvh makes it full height including on mobile browsers whose chrome comes and goes. Swap ml-auto/translate-x-full for mr-auto/-translate-x-full to open from the left, or use logical ms-auto if the app is bidirectional.
Toasts#
A toast is a transient message that must not steal focus. Two things make it accessible: it is announced by a live region, and it can be reached with the mouse without the region blocking the page underneath.
<div class="p-6">
<div class="pointer-events-none mx-auto flex w-full max-w-sm flex-col gap-3">
<div role="status" class="pointer-events-auto flex items-start gap-3 rounded-lg bg-white p-4 shadow-lg ring-1 ring-gray-900/10">
<svg class="size-5 shrink-0 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" stroke-linejoin="round" d="m8.5 12.5 2.5 2.5 4.5-5"/></svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900">Changes saved</p>
<p class="mt-0.5 text-sm text-gray-600">Your profile is up to date.</p>
</div>
<button type="button" class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" d="M6 18 18 6M6 6l12 12"/></svg>
<span class="sr-only">Dismiss</span>
</button>
</div>
<div role="alert" class="pointer-events-auto flex items-start gap-3 rounded-lg bg-white p-4 shadow-lg ring-1 ring-red-600/20">
<svg class="size-5 shrink-0 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" d="M12 8v5m0 3h.01"/></svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900">Upload failed</p>
<p class="mt-0.5 text-sm text-gray-600">report.pdf is larger than 10 MB.</p>
<button type="button" class="mt-2 text-sm font-semibold text-indigo-600 hover:text-indigo-500">Try again</button>
</div>
</div>
<div role="status" class="pointer-events-auto flex items-start gap-3 rounded-lg bg-white p-4 shadow-lg ring-1 ring-amber-600/20">
<svg class="size-5 shrink-0 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 3h.01M10.3 3.9 2.4 17.1A1.8 1.8 0 0 0 4 19.8h16a1.8 1.8 0 0 0 1.6-2.7L13.7 3.9a1.9 1.9 0 0 0-3.4 0Z"/></svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900">Trial ends in 3 days</p>
<p class="mt-0.5 text-sm text-gray-600">Add a payment method to keep your projects.</p>
</div>
</div>
<div role="status" class="pointer-events-auto flex items-start gap-3 rounded-lg bg-white p-4 shadow-lg ring-1 ring-sky-600/20">
<svg class="size-5 shrink-0 text-sky-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" d="M12 11v5m0-8h.01"/></svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900">Deploy queued</p>
<p class="mt-0.5 text-sm text-gray-600">Waiting for a build runner.</p>
</div>
</div>
</div>
</div>The container#
In a real application the wrapper is fixed, present from page load, and empty until something happens.
<!-- Polite queue: successes, progress, information -->
<div id="toasts" aria-live="polite" aria-atomic="false"
class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-3 p-4 sm:inset-x-auto sm:top-0 sm:right-0 sm:bottom-auto sm:items-end sm:p-6">
</div>
<!-- Assertive queue: errors that interrupt -->
<div id="alerts" role="alert" aria-live="assertive" class="sr-only"></div>pointer-events-none on the wrapper is essential: it is a full-width fixed layer, and without it the transparent strip swallows every click along the top or bottom of the page. Each toast then re-enables its own hit area with pointer-events-auto.
The live region must exist before the toast is inserted — a region added to the DOM at the same moment as its content is usually not announced.
| Variant | Role | Colour cue | Use for |
|---|---|---|---|
| Success | role="status" | text-green-600 | Confirmations of a completed action |
| Error | role="alert" | text-red-600 | Failures that need attention now |
| Warning | role="status" | text-amber-600 | Consequences that are approaching |
| Info | role="status" | text-sky-600 | Neutral progress and updates |
role="status" is announced when the user is idle; role="alert" interrupts. Reserve alert for genuine errors, or screen reader users get talked over.
Behaviour rules#
- Give every toast a text label. A coloured ring and an icon are not a message: "Upload failed" is.
- Do not auto-dismiss anything with an action in it. WCAG's timing requirements exist because five seconds is not enough time to read, decide and click.
- Pause the timer on hover and on focus, and never below about five seconds.
- Always include a dismiss button. It also gives keyboard users a way to reach and clear the toast.
- Cap the stack at three or four and drop the oldest — a wall of toasts is noise.
- Never put critical information only in a toast. It disappears; the state it describes should be visible somewhere permanent.
Tooltips#
A minimum viable tooltip needs four things: it appears on focus as well as hover, it is associated with its trigger through aria-describedby, it can be dismissed with Escape, and the pointer can move onto it without it vanishing (WCAG 1.4.13).
<div class="grid min-h-32 place-items-center p-6">
<span class="group relative inline-block">
<button type="button" aria-describedby="tip-1"
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Retention period
</button>
<span id="tip-1" role="tooltip"
class="invisible absolute top-full left-1/2 z-10 mt-2 w-56 -translate-x-1/2 rounded-md bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
Logs are kept for 30 days on the Pro plan, and 7 days on Starter.
</span>
</span>
</div>group-focus-within: is what makes it keyboard-reachable; aria-describedby is what makes it announced. Escape-to-dismiss still needs a keydown handler — one of several reasons a tested library is a reasonable choice here.
Do not put links or buttons inside a tooltip. Interactive content belongs in a popover, which can be entered and dismissed properly.
Z-index and the top layer#
Modal dialogs and popovers render in the top layer, above the entire page regardless of z-index or stacking contexts. That solves the classic "my modal is trapped behind a transformed parent" problem — but it has consequences:
z-indexon ashowModal()dialog does nothing. Top-layer elements stack in the order they were opened.- A
z-50toast container cannot appear above an open modal dialog. If toasts must show over a modal, either render them as a popover, or put them inside the dialog. - A
<div>modal is not in the top layer and is subject to every stacking context above it. This is the strongest practical argument for<dialog>.
For everything not in the top layer, use one named scale rather than scattered numbers:
@theme {
--z-dropdown: 10;
--z-sticky: 20;
--z-header: 30;
--z-overlay: 40;
--z-toast: 60;
}<div class="z-(--z-toast)">…</div>Common mistakes#
- A
<div>modal. No focus trap, no Escape, no inert background, no top layer — all things<dialog>gives you for nothing. show()instead ofshowModal(). Looks the same until you try to Tab out of it.- Forgetting
m-auto. Preflight removes the UAmargin: auto, so the dialog lands in the corner. starting:withouttransition-discrete. The dialog fades in and then vanishes instantly.- No
pointer-events-noneon the toast wrapper. An invisible fixed layer eats clicks across the page. - A live region created at the same time as its message. Nothing is announced; render the region up front.
role="alert"on every toast. Interrupting for a "Saved" confirmation is hostile.- Toasts that auto-dismiss with an action inside them. The action disappears before it can be used.
- Focus left behind after closing.
<dialog>restores it; a hand-rolled overlay must do it explicitly. titleused as a tooltip, or a hover-only tooltip with no focus equivalent.- Raising
z-indexto escape a stacking context. It cannot. Use the top layer, or fix the ancestor.
Related topics#
- Navigation — drawers, dropdowns and the header they open from
- Transitions —
transition-discrete, durations and easing - Overflow & Layering — stacking contexts and z-index scales
- Position —
fixed,stickyand containing blocks - Forms — forms inside a dialog and
method="dialog"