Alert Dialog
The <x-wirekit:: component creates a destructive confirmation dialog that requires explicit user action before proceeding. Unlike a regular Modal, the alert dialog uses role="alertdialog" and is non-dismissible by default -- a click on the backdrop does not close it, so a stray click cannot walk away from a destructive prompt. Escape always closes it, on every dialog and regardless of dismissible, because a dialog a keyboard user cannot leave is a keyboard trap (WCAG 2.1.2). The pointer path is the one the safety default constrains.
Usage
This live preview uses :dismissible="true" so you can explore and close it by clicking outside. In production, the default dismissible="false" takes the backdrop click away, so a pointer user has to choose Cancel or Confirm. Escape closes it either way.
Basic Setup
<x-wirekit::alert-dialog name="delete-project">
<x-wirekit::alert-dialog.title>Delete Project</x-wirekit::alert-dialog.title>
<x-wirekit::alert-dialog.description>
This will permanently delete the project and all associated data.
This action cannot be undone.
</x-wirekit::alert-dialog.description>
<x-wirekit::alert-dialog.actions>
<x-wirekit::button
intent="neutral"
surface="ghost"
size="sm"
x-on:click="$dispatch('wirekit-alert-dialog-close', { name: 'delete-project' })"
>
Cancel
</x-wirekit::button>
<x-wirekit::button intent="danger" size="sm" wire:click="deleteProject">
Delete Project
</x-wirekit::button>
</x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
Opening the Alert Dialog
<!-- Alpine -->
<x-wirekit::button
intent="danger"
x-on:click="$dispatch('wirekit-alert-dialog-show', { name: 'delete-project' })"
>
Delete Project
</x-wirekit::button>
<!-- Livewire (server-side) -->
$this->dispatch('wirekit-alert-dialog-show', name: 'delete-project');
<!-- Vanilla JS -->
window.dispatchEvent(new CustomEvent('wirekit-alert-dialog-show', {
detail: { name: 'delete-project' }
}));
An alert dialog without a trigger takes no room where you write it: its element draws no box, so it can stand in a row of buttons without adding a gap beside them.
From a trigger slot
A trigger slot renders the button that opens the dialog right where the dialog sits in your markup, without an event. In a container that stretches its children, add class="contents" to the alert dialog so the trigger lines up with its neighbors, the same way it works for a modal.
Closing the Alert Dialog
Three ways to close, all valid:
1. The <x-wirekit:: sub-component (recommended) — pre-wired to the parent dialog's close action, no manual $dispatch needed.
<x-wirekit::alert-dialog.actions>
<x-wirekit::alert-dialog.cancel />
<x-wirekit::button intent="danger" wire:click="delete">Delete</x-wirekit::button>
</x-wirekit::alert-dialog.actions>
Renders a neutral "Cancel" button by default. Override the label via the default slot, or wrap your own <x-wirekit:: for full control:
<x-wirekit::alert-dialog.cancel>Back</x-wirekit::alert-dialog.cancel>
<x-wirekit::alert-dialog.cancel>
<x-wirekit::button intent="neutral" surface="ghost">Discard</x-wirekit::button>
</x-wirekit::alert-dialog.cancel>
Confirming does not close the dialog. The example above is deliberately the plain one: press "Delete", the action fires and the panel stays up. That is more than cosmetic — the dialog holds aria-modal="true", so a role="status" your action renders on the page below is outside the modal and is never announced, and the backdrop keeps the page unreachable after the work is done.
Set close-on-confirm when the action ends the interaction:
<x-wirekit::alert-dialog name="delete-project" close-on-confirm>
It closes after the activation has been handled, so a wire:click on the same control still reaches the server. The default is false because closing is a behavior change for every dialog written before the prop existed, and some confirmations legitimately keep the panel up to show a result inside it.
<x-wirekit:: is not this, whatever its name suggests. It is a phrase guard: it refuses an activation until the confirmation string is typed. It fires nothing and closes nothing — so replacing a hand-written x-on:click="close()" with it removes the close.
The <div x-data> in the render above is preview plumbing: this page shows each block in
isolation, so the trigger's $dispatch needs an Alpine scope of its own. Your layout already
has one, which is why the code above does not carry it.
Press Archive: the action fires and the panel goes away, so whatever your handler renders underneath is reachable and gets announced. Compare it with the first preview on this page, where confirming leaves the dialog standing.
2. ESC key always closes — even when dismissible=false. Backdrop clicks stay blocked (safety against accidental destructive-action approval), but keyboard users always have an escape hatch.
3. Manual $dispatch from custom controls.
<button x-on:click="$dispatch('wirekit-alert-dialog-close', { name: 'delete-project' })">
Cancel
</button>
// Server-side from a Livewire component
$this->dispatch('wirekit-alert-dialog-close', name: 'delete-project');
Type-to-confirm
Some actions have no undo. For those, a button is not much of a brake — it is one pointer
movement away from the thing you cannot take back. Pass a confirmation-phrase and the
destructive control stays refused until that phrase has been typed.
Two pieces, and they sit in different places for a reason. alert-dialog. is the
field and belongs in the body, beside the description — alert-dialog. is a flex
row, and a text field dropped into it lands beside the buttons. alert-dialog. is the
control and belongs in actions, in place of the plain button.
What counts as typing it
The comparison trims the ends and is otherwise exact. Case, punctuation and inner spacing all count. Trimming is the one concession, and only because a trailing space arrives from a copy-paste rather than from a decision — nothing else is forgiven, because a brake that quietly accepts near-misses is not a brake.
The phrase stays visible while it is being typed. That is deliberate: a phrase you have to remember the wording of gets abandoned, and one that hides its target teaches people to paste it from somewhere else.
The wrapper cannot be activated while the phrase is unmet — click and Enter are both intercepted in the capture phase, before your own handler on the button inside it runs.
The button inside it carries aria-disabled rather than disabled, and that is not a
smaller version of the same thing. A disabled button is skipped by the tab order, so a
screen-reader user meets a control that does nothing and is never told why. aria-disabled
keeps it reachable, and the reason it names is announced when the control is focused. Both
attributes go on the control itself — on a wrapper around it, neither would reach anybody:
an ancestor's state says nothing about the button, and a description is read out only for
the element that has focus. If you slot your own button, it is the one that gets them.
Alert Dialog vs Modal
| Feature | Modal | Alert Dialog |
|---|---|---|
| ARIA role | role="dialog" |
role="alertdialog" |
| Dismissible by default | Yes (ESC + backdrop) | No -- the backdrop is inert, though ESC still closes |
aria-describedby |
Optional (via describedby prop) |
Automatic (linked to description) |
| Use case | General content, forms | Destructive confirmations, irreversible actions |
| Screen reader behavior | Announced as dialog | Announced as alert dialog (higher urgency) |
How many actions?
The alert dialog is shaped for a binary critical decision — a cancel and a confirm, with cancel first so it takes the safe initial focus. When a flow needs three or more actions — an unsaved-changes guard offering Save & close / Discard / Keep editing, for example — reach for Modal instead. Its wider content area suits a multi-way choice, and it does not carry the heightened role="alertdialog" urgency that a three-way "keep editing" branch does not need. Keep the alert dialog for the two-way "are you sure?".
Dismissible Override
In rare cases, you may want the alert dialog to be dismissible. Set dismissible to true:
<x-wirekit::alert-dialog name="soft-warning" :dismissible="true">
<x-wirekit::alert-dialog.title>Unsaved Changes</x-wirekit::alert-dialog.title>
<x-wirekit::alert-dialog.description>
You have unsaved changes. Are you sure you want to leave?
</x-wirekit::alert-dialog.description>
<x-wirekit::alert-dialog.actions>
<x-wirekit::button intent="neutral" surface="ghost" size="sm"
x-on:click="$dispatch('wirekit-alert-dialog-close', { name: 'soft-warning' })">
Stay
</x-wirekit::button>
<x-wirekit::button intent="danger" size="sm" wire:click="leave">
Discard Changes
</x-wirekit::button>
</x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
The role="alertdialog" contract is to force an explicit decision, so use dismissible sparingly on alert dialogs. If the action is not destructive, consider using a regular Modal instead.
With Livewire wire:model
<x-wirekit::alert-dialog name="confirm-delete" wire:model="showDeleteDialog">
<x-wirekit::alert-dialog.title>Confirm Deletion</x-wirekit::alert-dialog.title>
<x-wirekit::alert-dialog.description>
Are you sure? This cannot be undone.
</x-wirekit::alert-dialog.description>
<x-wirekit::alert-dialog.actions>
<x-wirekit::button intent="neutral" surface="ghost" size="sm"
x-on:click="$dispatch('wirekit-alert-dialog-close', { name: 'confirm-delete' })">
Cancel
</x-wirekit::button>
<x-wirekit::button intent="danger" size="sm" wire:click="confirmDelete">
Delete
</x-wirekit::button>
</x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
Width & Layout
Alert dialogs have a fixed max-width of 28rem (448px) and are always centered. This keeps them compact and focused — they are designed for a single confirmation question, not for complex content. For larger content, use a Modal instead.
Behavior
- Focus trap (bundled) keeps keyboard navigation within the dialog
- Scroll lock hides body scrollbar while the dialog is open
- Teleport renders the dialog at
<body>level for correct stacking - Scale transition for smooth open/close animation
- Livewire SPA navigation (
wire:navigate) automatically closes open alert dialogs - wire:model support syncs dialog visibility with a Livewire property
Where focus goes
This is the part that makes an alert dialog safe rather than merely modal, so it is worth being precise about.
On open, focus lands on Cancel — the least destructive action, as the APG alertdialog pattern requires. Press Enter by reflex and you cancel; you do not delete.
That guarantee needs the dialog to know which control is the safe one, so it
holds when you build the actions with <x-wirekit:::
<x-wirekit::alert-dialog.actions>
<x-wirekit::alert-dialog.cancel>
<x-wirekit::button intent="neutral" surface="ghost" size="sm">Cancel</x-wirekit::button>
</x-wirekit::alert-dialog.cancel>
<x-wirekit::button intent="danger" size="sm">Delete</x-wirekit::button>
</x-wirekit::alert-dialog.actions>
If your actions are all bare buttons, nothing identifies the harmless one and
focus falls back to the first focusable control in the panel — which may be the
destructive one. Either use the sub-component above, or name the control
yourself with initial-focus="#keep-it".
The usual delete-in-a-list pattern renders the confirmation inside the row it
deletes. When the action goes through, your re-render removes that row — and
with it the button the dialog would return focus to. A detached element cannot
hold focus, so the browser drops it on <body> and a keyboard or screen-reader
user is left with no position at all.
The dialog handles this: if its trigger is gone by the time it closes, focus
returns to the nearest surviving ancestor of that trigger — the table, the
list, the section — instead of <body>. Name a better target when you have one:
<x-wirekit::alert-dialog name="delete-role-{{ $role->id }}" focus-return-to="#roles-heading">
The order focus is resolved in
It depends on how the dialog closed, because only one kind of close leaves the reader expecting to be exactly where they were.
A dismissal — <x-wirekit::, Escape, or a backdrop click
on a dismissible dialog — acted on nothing:
- The dialog's own trigger, if it is still connected.
focusReturnTo, if it resolves to a connected element.- The nearest surviving ancestor of that trigger.
<body>— the state everything above exists to avoid.
Every other close — the confirmation, your own button calling close(), a close event —
is read as the action having gone through:
focusReturnTo, if it resolves to a connected element. A selector string, or a function returning an element.- The dialog's own trigger, if it is still connected.
- The nearest surviving ancestor of that trigger.
<body>.
A cancel control of your own counts as a dismissal too when it calls dismiss
instead of close().
After the action, focusReturnTo beats a surviving trigger. A confirmation closes before
your re-render removes the row, so at that moment the trigger still exists, and it would win if
it came first, only to vanish with its row a moment later. Set focusReturnTo when the trigger
does not survive the action; a dismissal goes back to the trigger either way.
The target must outlive the action. The selector is resolved at close time and
the element must still be connected. Point it at something inside the row you just
deleted and it silently falls through to step 3 — or to <body> — with no error
and no warning. Anchor it outside the region the action removes: the table, a
heading, the page's own container.
Props
<x-wirekit::alert-dialog>
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
required | Unique alert dialog identifier |
dismissible |
bool |
false |
Whether a backdrop click closes the dialog (default: non-dismissible). It does not gate ESC, which closes an alert dialog either way -- see Keyboard Interaction. |
lockScroll |
bool |
true |
Lock the page's scroll while the dialog is open. Set to false for a dialog that lives inside a page region, such as a preview, where the page around it has to keep scrolling |
initialFocus |
string|null |
null |
CSS selector (resolved inside the panel) for the control that should hold focus on open. Unset, focus goes to Cancel. |
focusReturnTo |
string|null |
null |
CSS selector (or a function returning an element) for where focus lands when the dialog closes after its action. There it beats a surviving trigger, so set it when the trigger does not outlive the action — and point it at something that does. A dismissal (Cancel, Escape, a backdrop click) returns to the trigger first. Unset, focus falls back to the trigger, then to its nearest surviving ancestor. See the order focus is resolved in. |
label |
string|null |
null |
An explicit accessible name, for a dialog composed without <x-wirekit::alert-dialog.title>. Without it, and without an aria-label you pass, such a dialog would announce as a bare "alert dialog": the built-in aria-labelledby points at an id only the title binds. The component reports that case through strict validation, as a modal does. WCAG 2.1 4.1.2 (Level A). |
describedby |
bool|null |
null |
Pass false for a dialog composed without <x-wirekit::alert-dialog.description>, which drops an aria-describedby that would otherwise reference an element that never renders. Any spelling works — :describedby="false", describedby="false", describedby="0". Leave it unset when you do compose the description. It was typed string|false|null here; a string was never used as an id, so it only ever read as boolean. |
confirmationPhrase |
string|null |
null |
The exact string a developer must type before <x-wirekit::alert-dialog.confirm> will fire. Unset, nothing is held back and the dialog behaves exactly as it did before. See Type-to-confirm. |
closeOnConfirm |
bool |
false |
Close the dialog once the destructive action has fired. Unset, the panel stays up after a confirmation — which leaves aria-modal="true" over a status message your action renders below it, where it is never announced. Closes after the activation is handled, so a wire:click on the same control still reaches the server. See Closing the Alert Dialog. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::alert-dialog.title>
| Prop | Type | Default | Description |
|---|---|---|---|
level |
int | 2 |
Heading level for the dialog's title, 1–6. The default is what it has always rendered. A dialog is a section of the page it opens over, not a page of its own, so the level that fits is the one below whatever heading the surrounding view ends on — set it to match that outline. A skipped level is what heading-order reports, and that rule sits in axe's best-practice tag rather than the WCAG tags most suites run, so it is invisible to an axe sweep and visible in Lighthouse. |
scope |
string|null |
null |
Scoped personalization name |
Sub-Components
| Component | Purpose |
|---|---|
alert-dialog.title |
Heading text (linked via aria-labelledby) |
alert-dialog.description |
Descriptive text (linked via aria-describedby). It may hold paragraphs or a list of what the action does, and all of it is read as the description |
alert-dialog.actions |
Container for cancel and confirm buttons. Right-aligned, and the buttons wrap onto another line when they do not fit the dialog |
alert-dialog.confirmation |
The type-to-confirm field. Renders nothing unless the dialog was given a confirmation-phrase. Belongs in the body, not in actions |
alert-dialog.confirm |
The destructive control, refused until the phrase matches. Safe to use without a phrase — it is then an ordinary wrapper |
Accessibility
- Dialog panel:
role="alertdialog"-- higher urgency thanrole="dialog"; screen readers announce it as an alert dialog aria-modal="true"-- indicates the dialog blocks interaction with the rest of the pagearia-labelledby-- automatically linked to thealert-dialog.sub-componenttitle aria-describedby-- automatically linked to thealert-dialog.sub-componentdescription - Focus trap active -- Tab cycles within the dialog only
- Focus returns to the trigger element on close
- The page behind is
inertwhile the dialog is open, so neither a pointer nor a screen reader that ignoresaria-modalreaches it. A toast region stays reachable - Scroll lock -- body scrollbar hidden while dialog is open
- Backdrop:
aria-hidden="true"(decorative) - Non-dismissible by default -- the backdrop click is taken away, so a stray pointer click cannot dismiss a destructive confirmation prompt
- Escape always closes, on every alert dialog and whatever
dismissibleis set to -- a dialog a keyboard user cannot leave is a keyboard trap under WCAG 2.1.2 (Level A). The safety default constrains the pointer, not the keyboard.
Keyboard Interaction
| Key | Action |
|---|---|
Tab / Shift+Tab |
Cycle focus between the action buttons (focus is trapped) |
Enter (on a focused button) |
Activate the focused button |
Escape |
Close the dialog -- always, including when dismissible is false |
Escape is the one closing path dismissible leaves alone: set it to false and the backdrop click goes away, while Escape keeps working, so a keyboard user always has a way out. Escape closes the dialog rather than pressing Cancel for the user — the Cancel button's own handler stays unrun, so put any cleanup a cancel needs on the dialog's close, not only on that click. For focus, Escape is a dismissal like Cancel: it returns to the trigger rather than to a focus-return-to target.
Pitfalls
- Don't use an alert-dialog for non-destructive confirmations. The component carries
role="alertdialog"and assertive ARIA — overuse desensitizes users to genuine warnings. For "Save changes?" reach for<x-wirekit::; for "Permanently delete?" usemodal> <x-wirekit::.alert-dialog> - Don't omit a clear cancel button. WAI-ARIA Authoring Practices require a way to dismiss without taking action. The component still renders without one: Escape closes it, but a pointer or a finger has nothing to tap except the action itself, because the backdrop stays inert unless
dismissibleis on.
Design Tokens
| Element | Token |
|---|---|
| Overlay background | --color-wk-overlay |
| Panel background | --color-wk-bg-elevated |
| Panel border | --color-wk-border / --border-wk-width |
| Panel radius | --radius-wk-xl |
| Panel shadow | --shadow-wk-lg |
| Title text | --color-wk-text |
| Title font weight | --font-wk-heading-weight |
| Description text | --color-wk-text-muted |
| Description font size | --text-wk-md |
| Space above the actions | --padding-wk-y-lg |
| Font family | --font-wk-sans |
| Transition | --transition-wk-duration |
Personalization
Override defaults in config/:
'components' => [
'alert-dialog' => [
'dismissible' => false,
],
],
Scoped Personalization
<x-wirekit::alert-dialog name="remove-user" scope="admin-confirm">
...
</x-wirekit::alert-dialog>
// In a service provider's boot() method. A closure receives the shipped classes, so the
// component keeps them; a plain string would replace them outright.
WireKit::scope('admin-confirm', [
'alert-dialog' => ['classes' => [
'panel' => fn (string $vendor): string => str_replace('max-w-[var(--size-wk-modal-sm)]', 'max-w-[var(--size-wk-modal-md)]', $vendor),
]],
]);
Further Reading
- Overlay Events — the canonical event vocabulary for showing, closing and reacting to every overlay, with payload shapes and Livewire-side dispatch examples.
- WAI-ARIA Alert Dialog Pattern -- the authoring pattern this component implements
- MDN:
role="alertdialog" - MDN:
aria-describedby - focus-trap -- focus management library (bundled)
- Modal component -- for non-destructive dialogs