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.
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)
<!-- 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
<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 |
<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 |
<x-wirekit::modal name="details" size="lg">
...
</x-wirekit::modal>
Non-Dismissible Modals
Prevent closing via ESC or backdrop click:
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:modelsyncs modal open/close state with a Livewire propertywire:ignore.selfprevents Livewire DOM morphing from destroying modal contentwire:navigate(SPA mode) automatically closes open modals and cleans up focus traps
Behavior
- 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:
<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:
<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:
<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
<!-- 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
<!-- 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
inertsemantics 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:clickdirectly on the.triggerslot. The trigger's Alpine handler dispatches the open event. A competingwire:clickwill 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 — the authoring pattern this component implements
- MDN:
role="dialog" - MDN:
aria-modal - focus-trap — focus management library (bundled, ~3.8 KB)