Skip to content
Brok UI

Loading…

No results

Modal Surfaces

Open source Core

The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.

Version
v1.10.1
Stability
stable
License
MIT
Related
Dialog
Drawer
Sheet

Alert Dialog — Modal confirmation dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Does not close on overlay click. `wire-action` names a Livewire method the confirm button calls (busy until it settles) before closing. `native` renders it without JavaScript as a native <dialog> or an inline confirm step that submits a real form.

Preview

Variant
previews.components.alert-dialog.default.blade.php Blade
<x-ui.alert-dialog>
    <x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
        {{ __('Delete account') }}
    </x-ui.alert-dialog.trigger>

    <x-ui.alert-dialog.content>
        <x-ui.alert-dialog.header>
            <x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
            <x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
        </x-ui.alert-dialog.header>

        <x-ui.alert-dialog.footer>
            <x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
            <x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
        </x-ui.alert-dialog.footer>
    </x-ui.alert-dialog.content>
</x-ui.alert-dialog>
Null Current
Primary Current
Danger Current

Installation

terminal
php artisan ui:add alert-dialog

Note

This component ships an Alpine behavior module at resources/js/ui/alert-dialog.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './alert-dialog.js';

Registry contract

php artisan ui:add alert-dialog writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/alert-dialog.blade.php
  • blade resources/views/components/ui/alert-dialog/trigger.blade.php
  • blade resources/views/components/ui/alert-dialog/content.blade.php
  • blade resources/views/components/ui/alert-dialog/header.blade.php
  • blade resources/views/components/ui/alert-dialog/title.blade.php
  • blade resources/views/components/ui/alert-dialog/description.blade.php
  • blade resources/views/components/ui/alert-dialog/footer.blade.php
  • blade resources/views/components/ui/alert-dialog/cancel.blade.php
  • blade resources/views/components/ui/alert-dialog/action.blade.php
  • js resources/js/ui/alert-dialog.js
Registry dependencies
dialog overlay form
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

alert-dialog.md
# Brok UI: Alert Dialog (`alert-dialog`)

Modal confirmation dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Does not close on overlay click. `wire-action` names a Livewire method the confirm button calls (busy until it settles) before closing. `native` renders it without JavaScript as a native <dialog> or an inline confirm step that submits a real form.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add alert-dialog
```

## Usage

```blade
<x-ui.alert-dialog>
    <x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
        {{ __('Delete account') }}
    </x-ui.alert-dialog.trigger>

    <x-ui.alert-dialog.content>
        <x-ui.alert-dialog.header>
            <x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
            <x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
        </x-ui.alert-dialog.header>

        <x-ui.alert-dialog.footer>
            <x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
            <x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
        </x-ui.alert-dialog.footer>
    </x-ui.alert-dialog.content>
</x-ui.alert-dialog>
```

## Props

- `wireAction` (string|null, default `null`) — Livewire component method the confirm action calls (via $wire.call) before the dialog closes; without it the confirm button just closes the dialog.
- `wireParams` (array, default `[]`) — Positional arguments passed to wireAction.
- `name` (string|null, default `null`) — Opens and closes the alert dialog on a window open-dialog / close-dialog event whose detail is { name }, like dialog. Null listens for nothing.
- `native` (false|'modal'|'inline', default `false`) — No-JavaScript mode. 'modal' (or a bare `native`) renders a native <dialog> opened by the trigger with an invoker command (command="show-modal"); 'inline' renders the confirm step in place for a confirm page. No Alpine, inline script or inline style, so it works under a strict CSP. Give each native alert dialog a unique `name`: its id is alert-dialog-<name>. wire-action, wire-params and the open-dialog events do not apply.
- `formAction` (string|null, default `null`) — Native modes: URL of the real form the action button submits. Without it the modal confirm is a <form method="dialog"> that only closes.
- `formMethod` (string, default `POST`) — Native modes: the form verb; PUT, PATCH and DELETE are method-spoofed by the form item, which also adds the CSRF field.
- `formHiddenFields` (array, default `[]`) — Native modes: hidden inputs as ['name' => 'value'], rendered by the form item's hidden-fields.
- `variant` (null|primary|danger, default `null`) — Documented catalog control used by the preview workbench.
- `size` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sticky` (bool, default `false`) — Declared by @props in the registry Blade source.
- `busyLabel` (mixed|null, default `null`) — Declared by @props in the registry Blade source.

## Use when

- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Confirming a consequential or destructive action, such as deleting a record, before it runs.
- Requiring the user to make an explicit choice before the dialog can be dismissed, unlike a dismissible informational modal.

## Avoid when

- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Use only when the user must acknowledge or decide about a consequential condition before continuing.
- Showing a general-purpose modal for editing or browsing content; use dialog instead, since alert-dialog does not close on overlay click.
- The confirmation is low-stakes and reversible; a plain button or toast undo is less disruptive.

## Anti-patterns

- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows

## Rules

- Use the `<brok:alert-dialog>` tag (or `<x-ui.alert-dialog>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/alert-dialog
- Registry JSON (files, props, contract): https://brokui.dev/r/open/alert-dialog.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

named.blade.php Blade
{{-- A named alert dialog opens on a window open-dialog event with its name,
     for example from a shortcut or a Livewire dispatch. --}}
<div class="flex flex-wrap items-center gap-2">
    <x-ui.button type="button" variant="destructive" x-data x-on:click="$dispatch('open-dialog', { name: 'discard-draft' })">
        {{ __('Discard draft') }}
    </x-ui.button>

    <x-ui.alert-dialog name="discard-draft">
        <x-ui.alert-dialog.content>
            <x-ui.alert-dialog.header>
                <x-ui.alert-dialog.title>{{ __('Discard this draft?') }}</x-ui.alert-dialog.title>
                <x-ui.alert-dialog.description>{{ __('The draft and its notes are removed. This cannot be undone.') }}</x-ui.alert-dialog.description>
            </x-ui.alert-dialog.header>
            <x-ui.alert-dialog.footer>
                <x-ui.alert-dialog.cancel>{{ __('Keep draft') }}</x-ui.alert-dialog.cancel>
                <x-ui.alert-dialog.action variant="destructive">{{ __('Discard') }}</x-ui.alert-dialog.action>
            </x-ui.alert-dialog.footer>
        </x-ui.alert-dialog.content>
    </x-ui.alert-dialog>
</div>
native.blade.php Blade
{{-- native: a native <dialog> opened by an invoker command. No Alpine, no
     inline script: it works under a strict CSP. The action submits a real
     form (DELETE spoofed by the form item, with CSRF and hidden fields). --}}
<div class="flex flex-wrap items-center gap-2">
    <x-ui.alert-dialog native name="delete-note" form-action="#" form-method="DELETE" :form-hidden-fields="['note' => 42]">
        <x-ui.alert-dialog.trigger class="h-10 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90">
            {{ __('Delete note') }}
        </x-ui.alert-dialog.trigger>
        <x-ui.alert-dialog.content>
            <x-ui.alert-dialog.header>
                <x-ui.alert-dialog.title>{{ __('Delete this note?') }}</x-ui.alert-dialog.title>
                <x-ui.alert-dialog.description>{{ __('The note and its revisions are removed. This cannot be undone.') }}</x-ui.alert-dialog.description>
            </x-ui.alert-dialog.header>
            <x-ui.alert-dialog.footer>
                <x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
                <x-ui.alert-dialog.action variant="destructive">{{ __('Delete') }}</x-ui.alert-dialog.action>
            </x-ui.alert-dialog.footer>
        </x-ui.alert-dialog.content>
    </x-ui.alert-dialog>
</div>
native-inline.blade.php Blade
{{-- native="inline": the confirm step in place, for a confirm page or for
     browsers without invoker commands. Cancel links back; the action posts. --}}
<x-ui.alert-dialog native="inline" name="revoke-client" form-action="#" :form-hidden-fields="['client' => 'claude-desktop']">
    <x-ui.alert-dialog.content>
        <x-ui.alert-dialog.header>
            <x-ui.alert-dialog.title>{{ __('Revoke Claude Desktop?') }}</x-ui.alert-dialog.title>
            <x-ui.alert-dialog.description>{{ __('It loses access at once and must be connected again to use your library.') }}</x-ui.alert-dialog.description>
        </x-ui.alert-dialog.header>
        <x-ui.alert-dialog.footer>
            <x-ui.alert-dialog.cancel href="#">{{ __('Keep access') }}</x-ui.alert-dialog.cancel>
            <x-ui.alert-dialog.action variant="destructive">{{ __('Revoke') }}</x-ui.alert-dialog.action>
        </x-ui.alert-dialog.footer>
    </x-ui.alert-dialog.content>
</x-ui.alert-dialog>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
wireAction string | null null Livewire component method the confirm action calls (via $wire.call) before the dialog closes; without it the confirm button just closes the dialog.
wireParams array [] Positional arguments passed to wireAction.
name string | null null Opens and closes the alert dialog on a window open-dialog / close-dialog event whose detail is { name }, like dialog. Null listens for nothing.
native false | 'modal' | 'inline' false No-JavaScript mode. 'modal' (or a bare `native`) renders a native <dialog> opened by the trigger with an invoker command (command="show-modal"); 'inline' renders the confirm step in place for a confirm page. No Alpine, inline script or inline style, so it works under a strict CSP. Give each native alert dialog a unique `name`: its id is alert-dialog-<name>. wire-action, wire-params and the open-dialog events do not apply.
formAction string | null null Native modes: URL of the real form the action button submits. Without it the modal confirm is a <form method="dialog"> that only closes.
formMethod string POST Native modes: the form verb; PUT, PATCH and DELETE are method-spoofed by the form item, which also adds the CSRF field.
formHiddenFields array [] Native modes: hidden inputs as ['name' => 'value'], rendered by the form item's hidden-fields.
variant null | primary | danger null Documented catalog control used by the preview workbench.
size mixed | null null Declared by @props in the registry Blade source.
sticky bool false Declared by @props in the registry Blade source.
busyLabel mixed | null null Declared by @props in the registry Blade source.

Slots

  • default — Compositional parts: trigger, content, header, title, description, footer, cancel, and action.
  • x-ui.alert-dialog.trigger — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.content — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.header — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.title — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.description — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.footer — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.cancel — Installed subcomponent from the registry item.
  • x-ui.alert-dialog.action — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

alert-dialog-cancel alert-dialog-description alert-dialog-footer alert-dialog-form alert-dialog-header alert-dialog-live-region alert-dialog-overlay alert-dialog-portal alert-dialog-title {{ $slotName }}

Behavior

  • Unlike a plain dialog, an alert dialog does not close on overlay click, so the user must choose cancel or confirm.
  • The action button runs the root's wireAction, if any, staying disabled and busy until the call resolves, then closes the dialog; without a wireAction it closes immediately.
  • Marks the rest of the page inert while open (reference-counted across stacked overlays) so a screen reader on a virtual cursor cannot browse behind the modal.
  • A nested layer can call pushEscapeLayer(close)/popEscapeLayer(close) to claim the first Escape press instead of the dialog closing; the global Escape handler also defers to any keydown that already called preventDefault().
  • Call announceSubjectChange(message) to tell assistive tech an already-open dialog's content changed in place; ordinary open is not announced twice.
  • Named alert dialogs (name="discard-draft"): a window open-dialog event with detail { name } opens it and close-dialog closes it (not while its wired action is busy). Focus returns to the element that had it (IC-003).
  • Native modal mode: the browser makes the page inert, closes on Escape and returns focus to the trigger; overlay clicks do not close it. Cancel is a formmethod="dialog" button that closes without submitting and takes focus on open; the action is a submit button of the real form (its name and value are sent).
  • Native inline mode renders the title, description and buttons in flow inside a section named by the title; cancel is a link to its href, else the previous URL. Use it for a confirm page, or as the fallback for browsers without invoker commands (before Chrome 135, Firefox 144 and Safari 26.2).
  • Native modes are for plain form posts: a Livewire re-render of the dialog closes it, so keep the default mode (with wire-action) inside Livewire components.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Focused overlay

Handle a short focused interruption or blocking decision.

Use when

  • Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
  • Confirming a consequential or destructive action, such as deleting a record, before it runs.
  • Requiring the user to make an explicit choice before the dialog can be dismissed, unlike a dismissible informational modal.

Avoid when

  • Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
  • Use only when the user must acknowledge or decide about a consequential condition before continuing.
  • Showing a general-purpose modal for editing or browsing content; use dialog instead, since alert-dialog does not close on overlay click.
  • The confirmation is low-stakes and reversible; a plain button or toast undo is less disruptive.

Use instead

  • Inline content
  • Drawer or dedicated page for longer tasks

Anti-patterns

  • Long forms in dialogs
  • Multi-step or comparison-heavy modal workflows
Anatomy
root trigger overlay content header title description footer cancel action form
Theming hooks
alert-dialog-cancel alert-dialog-description alert-dialog-footer alert-dialog-form alert-dialog-header alert-dialog-live-region alert-dialog-overlay alert-dialog-portal alert-dialog-title {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Escape
Focus
managed
  • The panel uses role="alertdialog" so assistive tech treats it as requiring an explicit response, unlike a plain dialog.
  • Background content is inert while the alert dialog is open; do not rely on aria-hidden or z-index alone to keep it from a virtual cursor.
  • In native modal mode the <dialog> carries role="alertdialog" with aria-labelledby and aria-describedby pointing at the title and description, and the rest of the page is inert while it is open.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="alert-dialog-{{ $record->id }}">
    <x-ui.alert-dialog>
        <x-ui.alert-dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background">
            {{ __('Delete account') }}
        </x-ui.alert-dialog.trigger>
    
        <x-ui.alert-dialog.content>
            <x-ui.alert-dialog.header>
                <x-ui.alert-dialog.title>{{ __('Are you absolutely sure?') }}</x-ui.alert-dialog.title>
                <x-ui.alert-dialog.description>{{ __('This action cannot be undone. This will permanently delete your account and remove your data from our servers.') }}</x-ui.alert-dialog.description>
            </x-ui.alert-dialog.header>
    
            <x-ui.alert-dialog.footer>
                <x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
                <x-ui.alert-dialog.action variant="destructive">{{ __('Delete account') }}</x-ui.alert-dialog.action>
            </x-ui.alert-dialog.footer>
        </x-ui.alert-dialog.content>
    </x-ui.alert-dialog>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:alert-dialog
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/alert-dialog.blade.php Blade
@props([
    // Livewire: the component method the confirm action calls (`$wire.call`)
    // before the dialog closes, with `wireParams` as positional arguments.
    // The action button is busy (disabled, aria-busy) until the call returns;
    // without Livewire the dialog simply closes. Null leaves the action a
    // plain close button that the consumer wires themselves.
    'wireAction' => null,
    'wireParams' => [],
    // Opens and closes on a window `open-dialog` / `close-dialog` event whose
    // detail names it ({ name }), like a named dialog. Null listens for
    // nothing.
    'name' => null,
    // No-JavaScript mode. false (default) keeps the Alpine dialog above.
    // 'modal' (or a bare `native`) renders a native <dialog> that the trigger
    // opens with an invoker command (command="show-modal"); 'inline' renders
    // the confirm step in place, for a confirm page or section. Neither mode
    // prints Alpine, inline script or inline style, so both work under a
    // strict Content-Security-Policy. `name` names the dialog (its id is
    // alert-dialog-<name>); give every native alert dialog on a page its own.
    'native' => false,
    // Native modes: the real form the action button submits. Without
    // form-action the modal confirm is a <form method="dialog"> that only
    // closes. form-method PUT/PATCH/DELETE are spoofed by the form item, and
    // form-hidden-fields renders its hidden inputs.
    'formAction' => null,
    'formMethod' => 'POST',
    'formHiddenFields' => [],
])

@php
    $config = [
        'action' => filled($wireAction) ? (string) $wireAction : null,
        'params' => array_values((array) $wireParams),
        'name' => filled($name) ? (string) $name : null,
    ];
    // livewire-confirm renders this component under its own name; the
    // browser keeps only the first of two identical attributes, so read the
    // override out of the bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog');
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
@endphp

@if ($nativeMode !== null)
{{-- Native mode: no Alpine root. The parts read the mode through @aware. --}}
<div
    data-slot="{{ $slotName }}"
    data-native="{{ $nativeMode }}"
    @if (filled($name)) data-dialog-name="{{ $name }}" @endif
    {{ $attributes->except('data-slot')->merge(['class' => 'contents']) }}
>
    {{ $slot }}
</div>
@else

{{--
    Alert Dialog (spec §17 dialog focus trap / Escape / focus return + §19 behavior).
    Root holds open state, focus trapping and focus restoration. Each instance
    is self-contained, so multiple and nested alert dialogs work. Unlike a plain
    dialog, an alert dialog does not close on overlay click.
--}}
<div
    x-data="uiAlertDialog({{ \Illuminate\Support\Js::from($config) }})"
    x-modelable="open"
    x-id="['alert-dialog-title', 'alert-dialog-description', 'alert-dialog-content']"
    data-slot="{{ $slotName }}"
    @if (filled($name)) data-dialog-name="{{ $name }}" @endif
    {{ $attributes->except('data-slot')->merge(['class' => 'contents']) }}
>
    {{ $slot }}
</div>
@endif
resources/views/components/ui/alert-dialog/trigger.blade.php Blade
@aware([
    'native' => false,
    'name' => null,
])

@props([
    // Button look for the trigger, from the button item's recipe: a variant
    // (default, destructive, outline, secondary, ghost, link) and a size (sm,
    // md, lg). Both null: the unstyled trigger, for a fully custom look.
    'variant' => null,
    'size' => null,
])

@php
    // livewire-confirm targets its trigger under its own name; print one data-slot.
    $slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-trigger');
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
    $dialogId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm');

    $triggerClasses = 'inline-flex items-center justify-center';
    $looksLikeButton = $variant !== null || $size !== null;
    if ($looksLikeButton) {
        $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
        $buttonStyles = $styles['button'];
        $variant = $styles['normalizeVariant']($variant) ?: 'default';
        $variant = ['primary' => 'default', 'danger' => 'destructive'][$variant] ?? $variant;
        $variant = isset($buttonStyles['variants'][$variant]) ? $variant : 'default';
        $size = isset($buttonStyles['sizes'][$size ?? 'md']) ? ($size ?? 'md') : 'md';
        $sizeClass = $variant === 'link'
            ? ($buttonStyles['linkSizes'][$size] ?? $buttonStyles['linkSizes']['md'])
            : $buttonStyles['sizes'][$size];
        $triggerClasses = trim($buttonStyles['base'].' '.$buttonStyles['variants'][$variant].' '.$sizeClass);
    }
    $lookAttributes = $looksLikeButton ? ['data-variant' => $variant, 'data-size' => $size] : [];
@endphp

@if ($nativeMode === 'modal')
    {{-- Opens the native <dialog> with an invoker command: no JavaScript. --}}
    <button
        type="button"
        data-slot="{{ $slotName }}"
        command="show-modal"
        commandfor="{{ $dialogId }}"
        aria-haspopup="dialog"
        {{ $attributes->except('data-slot')->merge(['class' => $triggerClasses, ...$lookAttributes]) }}
    >
        {{ $slot }}
    </button>
@elseif ($nativeMode === null)
    <button
        type="button"
        data-slot="{{ $slotName }}"
        x-ref="trigger"
        x-bind:aria-expanded="open"
        x-bind:aria-controls="$id('alert-dialog-content')"
        x-bind:disabled="busy"
        @click="show()"
        {{ $attributes->except('data-slot')->merge(['class' => $triggerClasses, ...$lookAttributes]) }}
    >
        {{ $slot }}
    </button>
@endif
resources/views/components/ui/alert-dialog/content.blade.php Blade
@aware([
    'native' => false,
    'name' => null,
    'formAction' => null,
    'formMethod' => 'POST',
    'formHiddenFields' => [],
])

@php
    // livewire-confirm targets its panel under its own name; print one data-slot.
    $slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-content');
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
@endphp

@if ($nativeMode !== null)
    @php
        $dialogId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm');
        $hasForm = filled($formAction);
        $panelClasses = 'relative w-full rounded-lg border border-border bg-card p-6 text-start text-card-foreground shadow-lg';
    @endphp

    @if ($nativeMode === 'modal')
        {{-- A native modal <dialog>: the browser traps the page (inert), closes
             it on Escape and returns focus to the trigger. The dialog is a
             transparent full-viewport frame, so the panel keeps its gutter on a
             narrow screen; it is display:none until open. --}}
        <dialog
            id="{{ $dialogId }}"
            data-slot="alert-dialog-portal"
            data-native="modal"
            role="alertdialog"
            aria-labelledby="{{ $dialogId }}-title"
            aria-describedby="{{ $dialogId }}-description"
            class="m-0 h-full max-h-none w-full max-w-none items-center justify-center overflow-y-auto bg-transparent p-4 text-foreground open:flex backdrop:bg-scrim/50"
        >
            @if ($hasForm)
                <x-ui.form :action="$formAction" :method="$formMethod" :hidden-fields="$formHiddenFields" class="w-full max-w-lg" data-slot="alert-dialog-form">
                    <div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
                        {{ $slot }}
                    </div>
                </x-ui.form>
            @else
                <form method="dialog" data-slot="alert-dialog-form" class="w-full max-w-lg">
                    <div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
                        {{ $slot }}
                    </div>
                </form>
            @endif
        </dialog>
    @else
        {{-- Inline: the confirm step in place (a confirm page or section). --}}
        <section
            id="{{ $dialogId }}"
            data-slot="alert-dialog-portal"
            data-native="inline"
            aria-labelledby="{{ $dialogId }}-title"
            aria-describedby="{{ $dialogId }}-description"
            class="w-full max-w-lg"
        >
            @if ($hasForm)
                <x-ui.form :action="$formAction" :method="$formMethod" :hidden-fields="$formHiddenFields" data-slot="alert-dialog-form">
                    <div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
                        {{ $slot }}
                    </div>
                </x-ui.form>
            @else
                <div data-slot="{{ $slotName }}" {{ $attributes->except('data-slot')->merge(['class' => $panelClasses]) }}>
                    {{ $slot }}
                </div>
            @endif
        </section>
    @endif
@else

<template x-teleport="body">
    <div
        data-slot="alert-dialog-portal"
        x-ref="portal"
        x-show="open"
        x-cloak
        class="fixed inset-0 z-modal flex items-center justify-center p-4"
        {{-- Defers to any surface that already handled the key (a nested menu
             calling preventDefault() on its own Escape) and to a nested layer
             that claims the first Escape via pushEscapeLayer() before this
             closes the dialog itself. --}}
        @keydown.escape.window="open && !$event.defaultPrevented && escape()"
    >
        {{-- Announces an in-place subject change (see announceSubjectChange())
             without re-announcing on ordinary open, which the dialog role
             already covers. --}}
        <p data-slot="alert-dialog-live-region" aria-live="polite" class="sr-only" x-text="subjectAnnouncement"></p>

        {{-- Overlay (alert dialogs do not close on overlay click) --}}
        <div
            data-slot="alert-dialog-overlay"
            x-show="open"
            x-transition:enter="motion-safe:transition-opacity motion-safe:duration-150"
            x-transition:enter-start="opacity-0"
            x-transition:enter-end="opacity-100"
            x-transition:leave="motion-safe:transition-opacity motion-safe:duration-150"
            x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            class="absolute inset-0 bg-scrim/50"
        ></div>

        {{-- Panel --}}
        <div
            data-slot="{{ $slotName }}"
            x-ref="panel"
            x-bind:id="$id('alert-dialog-content')"
            role="alertdialog"
            aria-modal="true"
            tabindex="-1"
            x-bind:aria-labelledby="$id('alert-dialog-title')"
            x-bind:aria-describedby="$id('alert-dialog-description')"
            x-show="open"
            x-transition:enter="motion-safe:transition motion-safe:duration-150"
            x-transition:enter-start="opacity-0 scale-95"
            x-transition:enter-end="opacity-100 scale-100"
            x-transition:leave="motion-safe:transition motion-safe:duration-150"
            x-transition:leave-start="opacity-100 scale-100"
            x-transition:leave-end="opacity-0 scale-95"
            @keydown.tab.prevent="trap($event)"
            {{ $attributes->except('data-slot')->merge(['class' => 'relative z-10 w-full max-w-lg rounded-lg border border-border bg-card p-6 text-card-foreground shadow-lg motion-reduce:transition-none']) }}
        >
            {{ $slot }}
        </div>
    </div>
</template>
@endif
resources/views/components/ui/alert-dialog/header.blade.php Blade
@props([
    // Pin the header to the top of a scrollable alert dialog body.
    'sticky' => false,
])

{{-- Anatomy shared with the other overlays — see ui/overlay/header.blade.php. --}}
<x-ui.overlay.header data-slot="alert-dialog-header" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.header>
resources/views/components/ui/alert-dialog/title.blade.php Blade
@aware([
    'native' => false,
    'name' => null,
])

@php
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
@endphp

@if ($nativeMode !== null)
    {{-- Native modes: a static id the dialog's aria-labelledby points at, no Alpine. --}}
    @php
        $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
        $titleId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm').'-title';
    @endphp
    <h2 id="{{ $titleId }}" data-slot="alert-dialog-title" {{ $attributes->except('data-slot')->merge(['class' => $styles['overlay']['title']]) }}>{{ $slot }}</h2>
@else
    {{-- Anatomy shared with the other overlays — see ui/overlay/title.blade.php. --}}
    <x-ui.overlay.title data-slot="alert-dialog-title" scope="alert-dialog-title" :track="false" {{ $attributes }}>{{ $slot }}</x-ui.overlay.title>
@endif
resources/views/components/ui/alert-dialog/description.blade.php Blade
@aware([
    'native' => false,
    'name' => null,
])

@php
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
@endphp

@if ($nativeMode !== null)
    {{-- Native modes: a static id the dialog's aria-describedby points at, no Alpine. --}}
    @php
        $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
        $descriptionId = 'alert-dialog-'.(\Illuminate\Support\Str::slug((string) $name) ?: 'confirm').'-description';
    @endphp
    <p id="{{ $descriptionId }}" data-slot="alert-dialog-description" {{ $attributes->except('data-slot')->merge(['class' => $styles['overlay']['description']]) }}>{{ $slot }}</p>
@else
    {{-- Anatomy shared with the other overlays — see ui/overlay/description.blade.php. --}}
    <x-ui.overlay.description data-slot="alert-dialog-description" scope="alert-dialog-description" :track="false" {{ $attributes }}>{{ $slot }}</x-ui.overlay.description>
@endif
resources/views/components/ui/alert-dialog/footer.blade.php Blade
@props([
    // Pin the footer to the bottom of a scrollable alert dialog body.
    'sticky' => false,
])

{{-- Anatomy shared with the other overlays — see ui/overlay/footer.blade.php. --}}
<x-ui.overlay.footer data-slot="alert-dialog-footer" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.footer>
resources/views/components/ui/alert-dialog/cancel.blade.php Blade
@aware([
    'native' => false,
])

@php
    $nativeMode = match (true) {
        $native === true, $native === 'modal', $native === 'true', $native === '1' => 'modal',
        $native === 'inline' => 'inline',
        default => null,
    };
    $cancelClasses = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none';
@endphp

@if ($nativeMode === 'modal')
{{-- Closes the native dialog without submitting the real form: a
     formmethod="dialog" submit works in every browser with <dialog>, no
     invoker command needed. It takes focus when the dialog opens, so Enter
     never confirms by accident. --}}
<button
    type="submit"
    formmethod="dialog"
    formnovalidate
    autofocus
    value="cancel"
    data-slot="alert-dialog-cancel"
    {{ $attributes->merge(['class' => $cancelClasses]) }}
>
    {{ $slot }}
</button>
@elseif ($nativeMode === 'inline')
{{-- Inline confirm page: cancel is a link back (href, else the previous URL). --}}
<a
    data-slot="alert-dialog-cancel"
    {{ $attributes->except('href')->merge(['class' => $cancelClasses]) }}
    href="{{ $attributes->get('href') ?: url()->previous() }}"
>
    {{ $slot }}
</a>
@else
<button
    type="button"
    data-slot="alert-dialog-cancel"
    x-bind:disabled="busy"
    @click="hide()"
    {{ $attributes->merge(['class' => 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none']) }}
>
    {{ $slot }}
</button>
@endif
resources/views/components/ui/alert-dialog/action.blade.php Blade
@aware([
    'native' => false,
])

@props([
    // default: the primary fill. destructive: the red fill for an action that
    // deletes or cannot be undone — the confirmation reads as the risk it is.
    'variant' => 'default',
    // Label shown while a `wire-action` call is in flight.
    'busyLabel' => null,
])

@php
    // Accept a string, a backed enum or a Stringable for `variant`.
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['default', 'destructive'], true) ? $variant : 'default';
    $fill = $variant === 'destructive'
        ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
        : 'bg-primary text-primary-foreground hover:bg-primary/90';
    // livewire-confirm targets its action under its own name; print one data-slot.
    $slotName = (string) ($attributes->get('data-slot') ?: 'alert-dialog-action');
    $isNative = in_array($native, [true, 'modal', 'true', '1', 'inline'], true);
    $actionClasses = 'inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none '.$fill;
@endphp

@if ($isNative)
{{-- Native modes: a submit button. With the root's form-action it posts the
     real form; without it, it closes the <form method="dialog">. A `name` and
     `value` on the button are sent with the form. --}}
<button
    type="submit"
    data-slot="{{ $slotName }}"
    data-variant="{{ $variant }}"
    {{ $attributes->except('data-slot')->merge(['class' => $actionClasses]) }}
>
    {{ $slot }}
</button>
@else

{{-- Confirms: runs the root's `wire-action` (if any) and closes. While the
     call is in flight the button is disabled, announces busy and swaps its
     label for `busyLabel`; without an action it closes immediately. --}}
<button
    type="button"
    data-slot="{{ $slotName }}"
    data-variant="{{ $variant }}"
    x-bind:disabled="busy"
    x-bind:aria-busy="busy"
    @click="run()"
    {{ $attributes->except('data-slot')->merge(['class' => 'inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none '.$fill]) }}
>
    <span x-show="busy" x-cloak class="size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" aria-hidden="true"></span>
    <span x-show="!busy">{{ $slot }}</span>
    <span x-show="busy" x-cloak>{{ $busyLabel ?? __('Confirming…') }}</span>
</button>
@endif
resources/js/ui/alert-dialog.js JS
// The dialog registry dependency installs this module beside this file.
import {
    announceOverlaySubjectChange,
    focusOverlayInitial,
    listenForOverlayName,
    lockOverlayInert,
    lockOverlayScroll,
    overlayFocusables,
    overlayReturnFocusTarget,
    popOverlayEscapeLayer,
    pushOverlayEscapeLayer,
    resolveOverlayEscape,
    trapOverlayFocus,
    unlockOverlayInert,
    unlockOverlayScroll,
} from './overlay-lifecycle.js';

/**
 * Alert Dialog behavior (spec §17: focus trap, Escape, focus return).
 *
 * On open we remember the element that had focus, move focus into the panel,
 * and lock body scroll. Tab is trapped inside the panel. On close we restore
 * focus to the trigger. Escape closes; overlay click does not.
 *
 * `config.action` (the Blade `wire-action` prop) names a Livewire component
 * method: run() calls it through `$wire` with `config.params`, holds the
 * dialog open and busy until it settles, then closes. Without Livewire (or
 * without an action) run() is a plain close, so the same markup is inert in
 * a static page.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiAlertDialog', (config = {}) => ({
        open: false,
        busy: false,
        action: config.action ?? null,
        params: Array.isArray(config.params) ? config.params : [],
        previouslyFocused: null,
        scrollLocked: false,
        inertLocked: false,
        inertPortal: null,
        escapeLayers: [],
        // Polite live region text — see announceSubjectChange().
        subjectAnnouncement: '',
        name: typeof config.name === 'string' && config.name !== '' ? config.name : null,
        stopListeningForName: null,

        init() {
            this.stopListeningForName = listenForOverlayName(this.name, {
                open: () => {
                    if (!this.open) this.show();
                },
                close: () => this.hide(),
            });
        },

        lockScroll() {
            lockOverlayScroll(this);
        },

        unlockScroll() {
            unlockOverlayScroll(this);
        },

        lockInert() {
            lockOverlayInert(this, this.$refs.portal);
        },

        unlockInert() {
            unlockOverlayInert(this);
        },

        /** A nested layer (an inline form, a stepper) claims Escape while active. */
        pushEscapeLayer(close) {
            pushOverlayEscapeLayer(this, close);
        },

        popEscapeLayer(close) {
            popOverlayEscapeLayer(this, close);
        },

        /** Announce an in-place subject change without re-announcing on open. */
        announceSubjectChange(message) {
            announceOverlaySubjectChange(this, message);
        },

        destroy() {
            this.stopListeningForName?.();
            this.stopListeningForName = null;
            this.unlockScroll();
            this.unlockInert();
        },

        show() {
            this.previouslyFocused = overlayReturnFocusTarget(this.$refs.trigger);
            this.open = true;
            this.lockScroll();
            this.lockInert();
            this.$nextTick(() => this.focusInitial());
        },

        /** The confirm path: call the wired action (if any), then close. */
        async run() {
            if (this.busy) {
                return;
            }
            if (!this.action || !window.Livewire || !this.$wire) {
                this.hide();
                return;
            }
            this.busy = true;
            try {
                await this.$wire.call(this.action, ...this.params);
            } finally {
                this.busy = false;
            }
            this.hide();
        },

        hide() {
            if (!this.open || this.busy) {
                return;
            }
            this.open = false;
            this.unlockScroll();
            this.unlockInert();
            const target = this.previouslyFocused || this.$refs.trigger;
            if (target) {
                this.$nextTick(() => target.focus());
            }
        },

        /** Escape unwinds one layer at a time before it closes the dialog. */
        escape() {
            if (!this.open) return;
            if (resolveOverlayEscape(this)) return;
            this.hide();
        },

        focusables() {
            return overlayFocusables(this.$refs.panel);
        },

        focusInitial() {
            focusOverlayInitial(this.$refs.panel);
        },

        /** Tab is always prevented in markup; we move focus manually. */
        trap(event) {
            trapOverlayFocus(this.$refs.panel, event);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.10.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0