---
title: Alert Dialog
description: Confirmation dialog with required action
visibility: guest
draft: false
---

# Alert Dialog

The `<x-wirekit::alert-dialog>` component creates a destructive confirmation dialog that requires explicit user action before proceeding. Unlike a regular [Modal](/components/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

:::preview{title="Delete Confirmation"}
<div x-data>
<x-wirekit::button intent="danger" @click="$dispatch('wirekit-alert-dialog-show', { name: 'delete-project' })">Delete Project</x-wirekit::button>

<x-wirekit::alert-dialog name="delete-project" :dismissible="true">
    <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::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 Project</x-wirekit::button>
    </x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
</div>
:::

:::source{language="blade"}
<x-wirekit::button intent="danger"
    @click="$dispatch('wirekit-alert-dialog-show', { name: 'delete-project' })">
    Delete Project
</x-wirekit::button>

<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::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" wire:click="deleteProject">Delete Project</x-wirekit::button>
    </x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
:::

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

```blade
<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

```blade
<!-- 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.

```blade
<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:

```blade
<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.**

```blade
<button x-on:click="$dispatch('wirekit-alert-dialog-close', { name: 'delete-project' })">
    Cancel
</button>
```

```php
// 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.

:::preview{title="Type to Confirm"}
<div x-data>
<x-wirekit::button intent="danger" @click="$dispatch('wirekit-alert-dialog-show', { name: 'drop-environment' })">Delete Environment</x-wirekit::button>

<x-wirekit::alert-dialog name="drop-environment" :dismissible="true" confirmation-phrase="delete production">
    <x-wirekit::alert-dialog.title>Delete this environment?</x-wirekit::alert-dialog.title>
    <x-wirekit::alert-dialog.description>
        Every deployment, secret and log line in it goes with it. There is no undo.
    </x-wirekit::alert-dialog.description>
    <x-wirekit::alert-dialog.confirmation />
    <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::alert-dialog.confirm>
            <x-wirekit::button intent="danger" size="sm">Delete Environment</x-wirekit::button>
        </x-wirekit::alert-dialog.confirm>
    </x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
</div>
:::

:::source{language="blade"}
<x-wirekit::button intent="danger"
    @click="$dispatch('wirekit-alert-dialog-show', { name: 'drop-environment' })">
    Delete Environment
</x-wirekit::button>

<x-wirekit::alert-dialog name="drop-environment" confirmation-phrase="delete production">
    <x-wirekit::alert-dialog.title>Delete this environment?</x-wirekit::alert-dialog.title>
    <x-wirekit::alert-dialog.description>
        Every deployment, secret and log line in it goes with it. There is no undo.
    </x-wirekit::alert-dialog.description>
    <x-wirekit::alert-dialog.confirmation />
    <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::alert-dialog.confirm wire:click="destroyEnvironment">
            <x-wirekit::button intent="danger" size="sm">Delete Environment</x-wirekit::button>
        </x-wirekit::alert-dialog.confirm>
    </x-wirekit::alert-dialog.actions>
</x-wirekit::alert-dialog>
:::

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.

::: warning 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](/components/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`:

```blade
<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>
```

::: info
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](/components/modal) instead.
:::

## With Livewire `wire:model`

```blade
<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](/components/modal) instead.

## Behavior

- **[Focus trap](https://github.com/focus-trap/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](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/)
requires. Press <kbd>Enter</kbd> 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>`:

```blade
<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"`.

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

```blade
<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:

1. **`focusReturnTo`, if it resolves to a connected element.** A selector string,
   or a function returning an element.
2. **The dialog's own trigger**, if it is still connected.
3. **The nearest surviving ancestor** of that trigger.
4. **`<body>`** — the state everything above exists to avoid.

::: warning 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](#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](#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 than `role="dialog"`; screen readers announce it as an alert dialog
- `aria-modal="true"` -- indicates the dialog blocks interaction with the rest of the page
- `aria-labelledby` -- automatically linked to the `alert-dialog.title` sub-component
- `aria-describedby` -- automatically linked to the `alert-dialog.description` sub-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`) |

::: info
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`:

```php
'components' => [
    'alert-dialog' => [
        'dismissible' => false,
    ],
],
```

### Scoped Personalization

```blade
<x-wirekit::alert-dialog name="remove-user" scope="admin-confirm">
    ...
</x-wirekit::alert-dialog>
```

```php
'personalizations' => [
    'alert-dialog' => [
        'admin-confirm' => [
            'base' => 'max-w-md',
        ],
    ],
],
```

## Further Reading

- [WAI-ARIA Alert Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) -- the authoring pattern this component implements
- [MDN: `role="alertdialog"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alertdialog_role)
- [MDN: `aria-describedby`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby)
- [focus-trap](https://github.com/focus-trap/focus-trap) -- focus management library (bundled, ~3.8 KB)
- [Modal component](/components/modal) -- for non-destructive dialogs
