Modal
The <x-wirekit:: 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::. The named slot renders outside the teleported panel and is always visible, while the sub-component renders inside the hidden panel.
Let the trigger follow the surrounding layout
The trigger sits inside the modal's own element, so a button there keeps its natural width in a container that stretches its children, such as a stack. Add class="contents" to the modal and its element steps out of the layout: the trigger becomes a child of that container and lines up with its neighbors.
With contents the modal's element no longer draws a box, so a class that styles one, such as a margin, has nothing to apply to. Put that spacing on the container instead.
Programmatic (via Event)
A modal with a name listens on window, so the event reaches it from anywhere in the document —
including across the teleport, and including from a different Livewire component.
<!-- Anything already inside an Alpine scope can send it -->
<div x-data="{}">
<button x-on:click="$dispatch('wirekit-modal-show', { name: 'confirm' })">Open</button>
</div>
<!-- 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>
A modal without a trigger takes no room where you write it: its element draws no box. It can stand in a row of buttons, such as the actions of a page header, without adding a gap beside them.
Alpine only walks elements inside an x-data tree. A button with an x-on:click and no
x-data above it anywhere is never walked, so the directive is not refused — it is never
installed. The button does nothing at all: no dialog, no error, nothing in the console. Even
modifiers stop applying, so an <a> in that position still navigates despite a .prevent.
If you have nowhere to put a
scope, use modal.trigger with a for —
it carries its own.
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>
Content that loads on open
A closed modal still carries its content in the page. Where that content is large, a column chooser with a checkbox for every column for instance, render it only while the modal is open: the action that opens it loads what it shows, and the content stays out of the page until then.
<x-wirekit::button wire:click="openColumns">Columns</x-wirekit::button>
<x-wirekit::modal name="columns" wire:model="showColumns" aria-label="Columns">
@if($showColumns)
@foreach($columns as $column)
<x-wirekit::checkbox wire:model="visible" :value="$column->key" :label="$column->label" />
@endforeach
@endif
</x-wirekit::modal>
Width & Layout
Modal width is controlled by the size prop — not by CSS classes. See Sizes below
for the max-width each key resolves to.
<x-wirekit::modal name="settings" size="lg">…</x-wirekit::modal>
Height grows with content up to the viewport. Past that, the header and footer stay where they are and modal.body scrolls between them, so the first line of a long document is always reachable. A modal that fits is vertically centered; one as tall as the viewport starts at its top edge.
Sizes
Each key maps onto a --size-wk-modal-* design token, so an app-wide override retunes every
modal at once — see Design Tokens.
| 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.
Asking Before Changes Are Discarded
A modal the reader can dismiss asks before it throws away what they typed. Once the reader has
changed a field in it, Escape, a click beside the dialog and the close button first ask "Discard
your changes?" in the reader's language, in an alert dialog over the
modal rather than with the browser's own question. Stay has the focus and keeps the modal and
everything typed into it, and so does Escape: the focus goes back to where the reader was in the
modal. Discard closes the modal the way the reader started to close it, and the focus goes back
to the control that opened it. A field that x-wk-unsaved marks as
unsaved counts as a change too.
It asks nothing when nothing changed, when the reader changed a field back, or when a Cancel
button closes the dialog, because pressing Cancel is the answer. A Cancel is a button that calls
dismiss, as below, or one inside <x-wirekit::, which also announces
the dismissal to your page. A close your page makes, through
wire:model or the close event, asks nothing either. The state the changes are measured against is
taken when the reader first presses or types into the dialog, so values your server fills in after
the dialog opened do not count as theirs. Each field is measured on its own: a field your server
shows later, such as the next step of a search that found nothing, counts once the reader changes
it, and a field your server removes counts no more.
Pass your own question with discard-question, or turn the question off for one modal with
:confirm-discard="false", or for every modal with 'confirm-discard' => false under
components. in config/. A field the question should ignore, such as a search box
that filters a list in the dialog, takes data-wk-discard-ignore. The two answers are the
translation keys wirekit::Stay and wirekit::, which your own catalog can word
differently, as Localization shows.
Livewire Integration
wire:modelsyncs modal open/close state with a Livewire propertywire:ignore.prevents Livewire DOM morphing from destroying modal contentself wire:navigate(SPA mode) automatically closes open modals and cleans up focus traps
Behavior
- Focus trap (bundled) prevents keyboard navigation outside the modal
- Scroll lock hides body scrollbar while modal is open
- Backdrop click closes the modal (when
dismissible), after asking when the reader changed a field in it - Teleport renders modal at
<body>level for correct stacking. Put a<form>inside the modal rather than around it: a form around the modal no longer contains the fields in it, and a plain submit leaves them out - 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". One Escape closes the top modal only, and the next one the modal below it. 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 |
confirmDiscard |
bool |
true |
Ask before the reader closes the modal by ESC, a backdrop click or the close button while its fields hold changes. See Asking Before Changes Are Discarded |
discardQuestion |
string|null |
null |
The question asked, in place of "Discard your changes?" in the reader's language |
lockScroll |
bool |
true |
Lock the page's scroll while the modal is open. Set to false for a modal that lives inside a page region, such as a preview, where the page around it has to keep scrolling |
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:: 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.trigger |
An element that opens the modal. Nested inside its own modal it needs nothing; given a for it names the modal instead and works from anywhere — see below |
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. Right-aligned, and the buttons wrap onto another line when they do not fit, so none is pushed off the panel on a phone |
modal.close |
Wraps any element to make it close the modal. With dismiss it is a Cancel: the modal closes as a dismissal and announces it, see below |
Opening a modal from somewhere the modal cannot reach
Give modal.trigger a for and it stops depending on where it sits: it names the modal it opens
and carries everything it needs with it.
| Prop | Effect |
|---|---|
for |
The name of the modal to open. With it the trigger brings its own Alpine scope and renders as a control rather than a <div>. Without it, nothing changes: the trigger is the nested one described above |
href |
Renders the trigger as an <a> with that address. Without href it is a <button> |
Why href matters more than it looks. With no JavaScript the anchor is still a link, so the
reader reaches the document. A consent sentence is the case that makes this a requirement rather
than a nicety: the agreement only holds if the text was reachable before the reader agreed, and a
word that does nothing when scripting is off is not reachable.
<label> there is noneThis is the reason for exists. Alpine only walks elements inside an x-data tree, so a
hand-written x-on:click on a link in a checkbox's sentence is not refused — it is never
installed, and even .prevent stops applying, so the link plainly navigates. Nothing is logged.
for puts the scope on the trigger itself, which is the one place that is always inside itself.
modal.trigger with a for also stops a click from reaching the surrounding <label>, so opening
the dialog does not tick the checkbox the reader has not agreed to yet.
Auto Close Button
By default, <x-wirekit:: 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" size="md" />
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::.
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');
When the reader dismisses it
A dismissible modal closes on its own when the reader clicks beside it, presses Escape or uses
the close button in the header, once the reader has chosen Discard for the fields they changed. It then sends wirekit:modal-dismissed on the window, with the
modal's name and the way it was dismissed (via: backdrop, escape or close-button), so a
page with state to clean up hears about a close it did not ask for:
{{-- 1. Clear the typed reason whenever the reader dismisses this modal --}}
<div x-on:wirekit:modal-dismissed.window="$event.detail.name === 'refund-reason' && $wire.discardReason()">
<x-wirekit::modal name="refund-reason">…</x-wirekit::modal>
</div>
A Cancel of your own is announced too when it sits in <x-wirekit::. It
closes the modal as a dismissal, so the focus goes back to the control that opened it, asks
nothing about changed fields, because pressing Cancel is the answer, and sends wirekit:modal-dismissed with
via: 'cancel':
<x-wirekit::modal.footer>
{{-- 1. Cancel: a dismissal, announced with via 'cancel' --}}
<x-wirekit::modal.close dismiss>
<x-wirekit::button intent="neutral" surface="outline">Cancel</x-wirekit::button>
</x-wirekit::modal.close>
{{-- 2. The action closes the modal from the server, which announces nothing --}}
<x-wirekit::button wire:click="refund">Refund</x-wirekit::button>
</x-wirekit::modal.footer>
It is sent for those four and no other close. A modal closed by wirekit-modal-close, by
a wire:model set to false, by a modal.close control of your own without dismiss or by a navigation
stays silent, so a page that closed its own modal never runs its cleanup twice.
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
- The question about changed fields is an alert dialog over the modal (
role="alertdialog"), named by the question, with Stay focused first. While it is open the modal below is no longeraria-modal, and on Stay the focus goes straight back to where it was, without passing the modal's first control - The page behind is
inertwhile the modal is open, so neither a pointer nor a screen reader that ignoresaria-modalreaches it. A toast region stays reachable, and a modal with:lock-scroll="false"leaves the page alone - Close button:
aria-label="Close" - Long content:
modal.bodyis the region that scrolls, and it carriestabindex="0"with a focus ring, so a keyboard can reach it and scroll it with the arrow keys. It is deliberately not a landmark. Initial focus still goes to the first control in the dialog when there is one, and to the body only when the dialog holds nothing but text - 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 dismissible), after asking when the reader changed a field in it; Escape on that question answers Stay. When a menu, a list or a tooltip inside the modal is open, the first Escape closes it and leaves the modal open, and the next one closes the modal |
| Click outside | Close the modal (when dismissible — one prop governs both) |
A control of your own inside the modal that closes something on Escape keeps the modal open by calling prevent on that key press, as the WireKit menus and lists do.
Which controls a Tab stops on is the browser's decision, and Safari on a Mac makes a different one: it stops on text fields and pop-up menus, passes links by unless Option is held, and passes buttons and other controls by unless Keyboard navigation is turned on in the Mac's keyboard settings. The modal follows the browser on every step that lands on a control inside it. Where Safari finds no control to stop on, because only ones it passes by are left, the next control of the modal takes the focus, so the keyboard does not leave a modal that is open. The same holds in every overlay that traps the focus.
Pitfalls
- Don't put
<x-wirekit::outside its parentmodal. trigger> <x-wirekit::without amodal> for. Without one the trigger calls into the scope it is nested in, so outside the parent there is no modal to open and it silently does nothing.foris exactly what removes that dependency — see Opening a modal from somewhere the modal cannot reach. - Prefer an alert-dialog over a second modal. Stacking modals IS supported — see Modal Stacking above, and
resources/keeps the global stack that makes it work — so this is a design preference, not a constraint: a confirm step reads better asjs/ utils/ overlay. js <x-wirekit::layered over the editor than as a second full modal.alert-dialog> - 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-lg |
Padding of the header, body and footer: one token on all four sides, so the title and the content start at the same distance from the panel's edge |
--padding-wk-y-xl |
The space kept between the panel and the edges of the viewport |
--z-wk-modal |
Stacking context |
Further Reading
- Overlay Events — the canonical event vocabulary for showing, closing and reacting to every overlay, with payload shapes and Livewire-side dispatch examples.
- WAI-ARIA Dialog (Modal) Pattern — the authoring pattern this component implements
- MDN:
role="dialog" - MDN:
aria-modal - focus-trap — focus management library (bundled)