Tailwind CSS Handbook v4.3

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.

Loading preview…

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:

  1. Focus moves in to the first focusable element, or to whatever has autofocus.
  2. 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.
  3. Esc closes it, firing cancel and then close.
  4. Focus returns to the element that opened it.
  5. It renders in the top layer, above every stacking context on the page.
  6. You get a ::backdrop to style with the backdrop: 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.

HTML
<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.

HTML
<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:

ClassMeaning
opacity-0Closed, and the state it animates to on exit
open:opacity-100The visible state, via the open variant ([open], :popover-open, :open)
starting:open:opacity-0The first frame when it opens, from @starting-style
transition-all transition-discreteIncludes 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:

JS
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:

JS
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.

HTML
<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:

HTML
<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.

Loading preview…
  • popovertarget on 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 popover attribute means popover="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.

HTML
<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.

Loading preview…

The container#

In a real application the wrapper is fixed, present from page load, and empty until something happens.

HTML
<!-- 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.

VariantRoleColour cueUse for
Successrole="status"text-green-600Confirmations of a completed action
Errorrole="alert"text-red-600Failures that need attention now
Warningrole="status"text-amber-600Consequences that are approaching
Inforole="status"text-sky-600Neutral 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).

Loading preview…

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-index on a showModal() dialog does nothing. Top-layer elements stack in the order they were opened.
  • A z-50 toast 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:

CSS
@theme {
  --z-dropdown: 10;
  --z-sticky: 20;
  --z-header: 30;
  --z-overlay: 40;
  --z-toast: 60;
}
HTML
<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 of showModal(). Looks the same until you try to Tab out of it.
  • Forgetting m-auto. Preflight removes the UA margin: auto, so the dialog lands in the corner.
  • starting: without transition-discrete. The dialog fades in and then vanishes instantly.
  • No pointer-events-none on 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.
  • title used as a tooltip, or a hover-only tooltip with no focus equivalent.
  • Raising z-index to escape a stacking context. It cannot. Use the top layer, or fix the ancestor.
  • 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, sticky and containing blocks
  • Forms — forms inside a dialog and method="dialog"