---
title: Modal
description: Dialog overlay with backdrop
visibility: guest
draft: false
---

# Modal

The `<x-wirekit::modal>` component creates accessible dialog overlays with focus trapping, scroll lock, and keyboard support. Modals are teleported to `<body>` for proper stacking.

## Usage

### With Named Trigger Slot

Use the `trigger` named slot to render a visible button that opens the modal. The trigger renders **outside** the teleported panel, so it is always visible on the page.

:::preview{title="Modal with Trigger"}
<x-wirekit::modal name="confirm-action">
    <x-slot:trigger>
        <x-wirekit::button>Open Modal</x-wirekit::button>
    </x-slot:trigger>
    <x-wirekit::modal.header>Confirm Action</x-wirekit::modal.header>
    <x-wirekit::modal.body>Are you sure you want to proceed?</x-wirekit::modal.body>
    <x-wirekit::modal.footer>
        <x-wirekit::modal.close>
            <x-wirekit::button intent="neutral" surface="ghost" size="sm">Cancel</x-wirekit::button>
        </x-wirekit::modal.close>
        <x-wirekit::button intent="danger" size="sm">Delete</x-wirekit::button>
    </x-wirekit::modal.footer>
</x-wirekit::modal>
:::

::: tip
Prefer `<x-slot:trigger>` over `<x-wirekit::modal.trigger>`. The named slot renders outside the teleported panel and is always visible, while the sub-component renders inside the hidden panel.
:::

### Programmatic (via Event)

```blade
<!-- Trigger from anywhere -->
<button x-on:click="$dispatch('wirekit-modal-show', { name: 'confirm' })">
    Open
</button>

<!-- Modal (can be in a different part of the page) -->
<x-wirekit::modal name="confirm">
    <x-wirekit::modal.header>Confirm</x-wirekit::modal.header>
    <x-wirekit::modal.body>Content here</x-wirekit::modal.body>
</x-wirekit::modal>
```

### With Livewire `wire:model`

```blade
<x-wirekit::modal name="edit-user" wire:model="showEditModal" size="lg">
    <x-wirekit::modal.header>Edit User</x-wirekit::modal.header>
    <x-wirekit::modal.body>
        <!-- Form fields -->
    </x-wirekit::modal.body>
    <x-wirekit::modal.footer>
        <x-wirekit::modal.close>
            <x-wirekit::button intent="neutral" surface="ghost">Cancel</x-wirekit::button>
        </x-wirekit::modal.close>
        <x-wirekit::button wire:click="save">Save</x-wirekit::button>
    </x-wirekit::modal.footer>
</x-wirekit::modal>
```

## Width & Layout

Modal width is controlled by the `size` prop — not by CSS classes:

| Size | Max Width |
| --- | --- |
| `sm` | 24rem (384px) |
| `md` | 28rem (448px) |
| `lg` | 32rem (512px) |
| `xl` | 36rem (576px) |
| `full` | 100% viewport |

```blade
<x-wirekit::modal name="settings" size="lg">…</x-wirekit::modal>
```

Height grows with content and scrolls when exceeding the viewport. The modal is always vertically centered.

## Sizes

| Size | Max Width | Use Case |
| --- | --- | --- |
| `sm` | 24rem (384px) | Simple confirmations |
| `md` | 32rem (512px) | Standard dialogs (default) |
| `lg` | 42rem (672px) | Forms with multiple fields |
| `xl` | 56rem (896px) | Complex content, tables |
| `full` | 100% | Full-width overlays |

```blade
<x-wirekit::modal name="details" size="lg">
    ...
</x-wirekit::modal>
```

## Non-Dismissible Modals

Prevent closing via ESC or backdrop click:

:::preview{title="Non-Dismissible Modal"}
<x-wirekit::modal name="required-action" :dismissible="false">
    <x-slot:trigger>
        <x-wirekit::button>Open Non-Dismissible</x-wirekit::button>
    </x-slot:trigger>
    <x-wirekit::modal.header>Required</x-wirekit::modal.header>
    <x-wirekit::modal.body>You must complete this action.</x-wirekit::modal.body>
    <x-wirekit::modal.footer>
        <x-wirekit::modal.close>
            <x-wirekit::button size="sm">Complete</x-wirekit::button>
        </x-wirekit::modal.close>
    </x-wirekit::modal.footer>
</x-wirekit::modal>
:::

The user can only close by clicking the explicit action button.

## Keyboard

| Key | Action |
| --- | --- |
| Escape | Close modal (when `dismissible`) |
| Tab / Shift+Tab | Cycle focus within modal (focus trap) |

## Livewire Integration

- `wire:model` syncs modal open/close state with a Livewire property
- `wire:ignore.self` prevents Livewire DOM morphing from destroying modal content
- `wire:navigate` (SPA mode) automatically closes open modals and cleans up focus traps

## Behavior

- **[Focus trap](https://github.com/focus-trap/focus-trap)** (bundled ~3.8 KB) prevents keyboard navigation outside the modal
- **Scroll lock** hides body scrollbar while modal is open
- **Backdrop** click closes the modal (when `dismissible`)
- **Teleport** renders modal at `<body>` level for correct stacking
- **Scale transition** for smooth open/close animation

## Modal Stacking

Opening a modal on top of another is supported. A global active-overlay stack tracks the open dialogs; the topmost one owns ESC, focus, and `aria-modal="true"`. Underlying modals stay rendered, their focus trap pauses, and their `aria-modal` flips to `"false"` so screen readers announce only the active dialog. Closing the top modal resumes focus inside the previous one and restores its `aria-modal` to `"true"`. Use this for "confirm before discard" patterns where a dismissal modal must appear over an editor modal already in flight.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | **required** | Unique modal identifier |
| `size` | `string` | `'md'` | Width: `sm`, `md`, `lg`, `xl`, `full` |
| `dismissible` | `bool` | `true` | Whether ESC and backdrop click close the modal |
| `ariaLabel` | `string\|null` | `null` | Names the dialog when it has no header. Required — as a prop or an `aria-label` attribute — if you omit `<x-wirekit::modal.header>` |
| `describedby` | `string\|null` | `null` | Element ID referenced by the dialog's `aria-describedby` (wire to `<x-wirekit::modal.body id="...">`) |
| `scope` | `string\|null` | `null` | Scoped personalization key |

### Naming the dialog

Every dialog needs an accessible name, or a screen reader announces it as just
"dialog". A `<x-wirekit::modal.header>` provides one automatically — its title
becomes the dialog's name.

If your modal has no header, name it yourself:

```blade
<x-wirekit::modal name="confirm-delete" ariaLabel="Delete this item?">
    <x-wirekit::modal.body>This cannot be undone.</x-wirekit::modal.body>
</x-wirekit::modal>
```

An `aria-label` attribute works the same way and takes precedence over the prop.
With neither a header nor a name, the component tells you during development
rather than shipping an unnamed dialog.

### Accessible Description

Pair the `describedby` prop with an `id` on your body for screen-reader context:

```blade
<x-wirekit::modal name="confirm" describedby="confirm-desc">
    <x-wirekit::modal.header>Delete account?</x-wirekit::modal.header>
    <x-wirekit::modal.body id="confirm-desc">
        This will permanently delete all your data. This action cannot be undone.
    </x-wirekit::modal.body>
</x-wirekit::modal>
```

Screen readers will announce the title (via `aria-labelledby`) **and** read the body description when the dialog opens.

## Sub-Components

| Component | Purpose |
| --- | --- |
| `<x-slot:trigger>` | **Recommended.** Named slot for the trigger button (renders outside teleport) |
| `modal.header` | Title/heading area with bottom border + **auto close-X** (dismissible only) |
| `modal.body` | Main scrollable content area |
| `modal.footer` | Action buttons area with top border |
| `modal.close` | Wraps any element to make it close the modal |

### Auto Close Button

By default, `<x-wirekit::modal.header>` renders a close-X in the top-right corner — matching the standard UX convention established by Radix, Headless UI, shadcn, Material, and Apple HIG. The button is Alpine-gated with `x-show="dismissible"`, so non-dismissible confirmation dialogs (`:dismissible="false"`) stay visually clean with no escape hatch surfaced to the user.

Opt out of the auto close button when you want full control over the header layout:

```blade
<x-wirekit::modal.header :close="false">
    <div class="flex items-center gap-2">
        <x-wirekit::icon name="warning" class="h-5 w-5" />
        Custom Header Layout
    </div>
</x-wirekit::modal.header>
```

The auto close button uses `aria-label="Close"`, has a WCAG-compliant focus ring, and respects the same personalization system as the rest of the header — style it via `WireKit::personalize('modal.header', ['close' => 'your-classes'])`.

## Events

### Opening

```blade
<!-- Alpine -->
<button x-on:click="$dispatch('wirekit-modal-show', { name: 'confirm' })">

<!-- Livewire (server-side) -->
$this->dispatch('wirekit-modal-show', name: 'confirm');

<!-- Vanilla JS -->
window.dispatchEvent(new CustomEvent('wirekit-modal-show', { detail: { name: 'confirm' } }));
```

### Closing

```blade
<!-- Alpine -->
<button x-on:click="$dispatch('wirekit-modal-close', { name: 'confirm' })">

<!-- Livewire (server-side) -->
$this->dispatch('wirekit-modal-close', name: 'confirm');
```

## Accessibility

- Dialog: `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
- Focus trap active — Tab cycles within the modal only
- Focus returns to the trigger element after close
- Close button: `aria-label="Close"`
- Backdrop: `aria-hidden="true"` (decorative)

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` / `Shift+Tab` | Cycle focus inside the modal (focus is trapped — see `resources/js/utils/focus-trap.js`) |
| `Escape` | Close the modal (when `closeOnEsc` is not disabled) |
| Click outside | Close the modal (when `closeOnOverlay` is not disabled) |

## Pitfalls

- **Don't put `<x-wirekit::modal.trigger>` outside its parent `<x-wirekit::modal>`.** The trigger reads its target via the named slot context — outside the parent it has no modal to open and silently does nothing.
- **Don't nest two modals.** Focus-trap stacking and `inert` semantics break — the inner modal's focus trap fights the outer one. Use a confirm dialog (`<x-wirekit::alert-dialog>`) layered above an open modal instead.
- **Don't call `wire:click` directly on the `.trigger` slot.** The trigger's Alpine handler dispatches the open event. A competing `wire:click` will run BEFORE the open and may navigate away before the modal renders.

## Design Tokens

| Token | Used for |
| --- | --- |
| `--font-wk-sans` | Modal font family |
| `--font-wk-heading-weight` | Header font weight |
| `--text-wk-md` / `--text-wk-lg` | Body / heading font size |
| `--color-wk-text` | Body text |
| `--color-wk-text-muted` | Header subtitle text |
| `--color-wk-bg-elevated` | Modal panel background |
| `--color-wk-bg-muted` | Footer background |
| `--color-wk-border` / `--color-wk-border-subtle` | Panel + footer divider border |
| `--color-wk-overlay` | Backdrop scrim color |
| `--color-wk-ring` | Close-button focus ring |
| `--ring-wk-width` | Focus ring width |
| `--border-wk-width` | Border width |
| `--radius-wk-sm` / `--radius-wk-xl` | Close-button + panel radius |
| `--shadow-wk-lg` | Panel drop shadow |
| `--size-wk-modal-sm` / `--size-wk-modal-md` / `--size-wk-modal-lg` / `--size-wk-modal-xl` / `--size-wk-modal-full` | Panel width per `size` prop |
| `--size-wk-sm` | Close-button hit target |
| `--gap-wk-md` | Footer-button gap |
| `--padding-wk-x-sm` / `--padding-wk-x-lg` / `--padding-wk-x-xl` | Horizontal padding (close button + panel sections) |
| `--padding-wk-y-md` / `--padding-wk-y-xl` | Vertical padding |
| `--z-wk-modal` | Stacking context |

## Further Reading

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