Alert Dialog
The <x-wirekit::alert-dialog> 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 -- the user cannot close it by pressing Escape or clicking the backdrop. This ensures destructive actions are always explicitly confirmed or canceled.
Usage
This live preview uses :dismissible="true" so you can explore and close it by clicking outside. In production, the default dismissible="false" forces an explicit Cancel/Confirm choice.
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="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' }
}));
Closing the Alert Dialog
Three ways to close, all valid:
1. The <x-wirekit::alert-dialog.cancel> 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::button> 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>
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.confirmation is the
field and belongs in the body, beside the description — alert-dialog.actions is a flex
row, and a text field dropped into it lands beside the buttons. alert-dialog.confirm is the
control and belongs in actions, where the plain button used to be.
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 control is refused, not merely styled as refused 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.
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.
Alert Dialog vs Modal
| Feature | Modal | Alert Dialog |
|---|---|---|
| ARIA role | role="dialog" |
role="alertdialog" |
| Dismissible by default | Yes (ESC + backdrop) | No -- requires explicit action |
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 ~3.8 KB) 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::alert-dialog.cancel>:
<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".
Focus after a confirmation that deletes its own trigger
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
Four steps, in this order, and the first two matter more than they look:
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>— the state everything above exists to avoid.
Two consequences of that order
focusReturnTo beats a surviving trigger. It is not a fallback for the case
where the trigger disappears — it wins whenever it resolves. Set it on a dialog
whose trigger usually survives and you pull focus away from where the reader was,
which is worse than the default. Set it only when the trigger genuinely does not
survive the action.
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 ESC and backdrop click close the dialog (default: non-dismissible) |
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 on close. Beats a surviving trigger, so set it only when the trigger does not outlive the action — and point it at something that does. 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 such a dialog announces as a bare "dialog": the built-in aria-labelledby points at an id the title would have bound, and an aria-label you pass lands on the outer wrapper rather than the element that carries the role. WCAG 2.1 4.1.2 (Level A). |
describedby |
string|false|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. Leave it unset when you do compose the description. |
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. |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
alert-dialog.title |
Heading text (linked via aria-labelledby) |
alert-dialog.description |
Descriptive text (linked via aria-describedby) |
alert-dialog.actions |
Container for cancel and confirm buttons |
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.titlesub-componentaria-describedby-- automatically linked to thealert-dialog.descriptionsub-component- Focus trap active -- Tab cycles within the dialog only
- Focus returns to the trigger element on close
- Scroll lock -- body scrollbar hidden while dialog is open
- Backdrop:
aria-hidden="true"(decorative) - Non-dismissible by default -- forces the user to make an explicit choice, preventing accidental dismissal of destructive confirmation prompts
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 |
Trigger the cancel action (only when dismissible is true) |
When dismissible is false (the default), Escape does nothing. The user must click one of the action buttons to close the dialog.
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::modal>; for "Permanently delete?" use<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 refuses to render without both action slots.
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 |
| Actions border | --color-wk-border-subtle |
| Actions padding | --padding-wk-x-md |
| Font family | --font-wk-sans |
| Transition | --transition-wk-duration |
Personalization
Override defaults in config/wirekit.php:
'components' => [
'alert-dialog' => [
'dismissible' => false,
],
],
Scoped Personalization
<x-wirekit::alert-dialog name="remove-user" scope="admin-confirm">
...
</x-wirekit::alert-dialog>
'personalizations' => [
'alert-dialog' => [
'admin-confirm' => [
'base' => 'max-w-md',
],
],
],
Further Reading
- WAI-ARIA Alert Dialog Pattern -- the authoring pattern this component implements
- MDN:
role="alertdialog" - MDN:
aria-describedby - focus-trap -- focus management library (bundled, ~3.8 KB)
- Modal component -- for non-destructive dialogs