Skip to main content
Copy for LLM

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.

Modal with Trigger

Discard your changes?

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.

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.

Trigger in a stretched stack

Discard your changes?

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.

The sender needs a scope, and without one it fails silently

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:

Non-Dismissible Modal

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 dismissOverlay(), as below, or one inside <x-wirekit::modal.close dismiss>, 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.

A form that asks before it is discarded

Discard your changes?

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.modal in config/wirekit.php. 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::Discard, which your own catalog can word differently, as Localization shows.

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

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

Discard your changes?

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.

The nested trigger needs a scope, and inside a <label> there is none

This 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::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" 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::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');

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::modal.close dismiss>. 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 longer aria-modal, and on Stay the focus goes straight back to where it was, without passing the modal's first control
  • The page behind is inert while the modal is open, so neither a pointer nor a screen reader that ignores aria-modal reaches 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.body is the region that scrolls, and it carries tabindex="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 preventDefault() 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::modal.trigger> outside its parent <x-wirekit::modal> without a 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. for is 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/js/utils/overlay.js keeps the global stack that makes it work — so this is a design preference, not a constraint: a confirm step reads better as <x-wirekit::alert-dialog> layered over the editor than as a second full modal.
  • 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-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

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