Skip to main content
WireKit
Copy for LLM

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

Delete Confirmation

Delete Project

This will permanently delete the project and all associated data. This action cannot be undone.

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.

Type to Confirm

Delete this environment?

Every deployment, secret and log line in it goes with it. There is no undo.

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:

  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.

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 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)

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

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store