Skip to main content
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 -- 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

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

Trigger in a stretched stack

Disable two-factor authentication?

Your account is then protected by its password alone.

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>

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::alert-dialog.confirm> 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.

Closing on confirm

Archive this run

The run moves out of the active list. You can restore it from the archive for thirty days.

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.

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

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

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::alert-dialog.cancel>, Escape, or a backdrop click on a dismissible dialog — acted on nothing:

  1. The dialog's own trigger, if it is still connected.
  2. focusReturnTo, if it resolves to a connected element.
  3. The nearest surviving ancestor of that trigger.
  4. <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:

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

A cancel control of your own counts as a dismissal too when it calls dismissOverlay() instead of close().

Two consequences of that order

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 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
  • The page behind is inert while the dialog is open, so neither a pointer nor a screen reader that ignores aria-modal reaches 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 dismissible is 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::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 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 dismissible is 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/wirekit.php:

'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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store