Skip to main content
WireKit
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

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:

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

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

Was this page helpful?

Voting requires cookies or local storage. What we store