salioudiabate/modal

A fluent, self-contained modal/dialog system for Livewire — open any Livewire component in a stacked, accessible dialog with one line, style it with a soft, modern design system out of the box.
8
Install
composer require salioudiabate/modal
Latest Version:v1.0.0
PHP:^8.3
License:MIT
Last Updated:Aug 21, 2026
Links: GitHub  ·  Packagist
Maintainer: salioudiabate

Modal

Tests Latest Version Total Downloads License

A fluent, self-contained modal/dialog system for Livewire. Open any Livewire component in a stacked, accessible dialog with one line; style it with a soft, modern design system out of the box — no Tailwind required, no build step.

Modal::open(EditUser::class)->with(['user' => $user])->show();
<x-modal::trigger component="edit-user" :arguments="['user' => $user->id]">
    Edit
</x-modal::trigger>

Companion package to salioudiabate/notify (toasts, alerts, confirms) — same design language, same conventions, meant to be used together.

Requirements

  • PHP ^8.3
  • Laravel 11, 12 or 13
  • Livewire 3 or 4

Installation

composer require salioudiabate/modal

Add the root component once, in your main layout (right before @livewireScripts/</body> is a good spot):

<x-modal::root />

That's it — no npm install, no Vite entry. The JS/CSS are served directly by the package. To self-host them through your own asset pipeline instead:

php artisan vendor:publish --tag=modal-assets

then set 'assets' => ['serve' => false] in config/modal.php and include public/vendor/modal/modal.{js,css} yourself.

Publish the config file if you want to change any default:

php artisan vendor:publish --tag=modal-config

Creating a modal

php artisan make:modal EditUser

This generates app/Livewire/EditUser.php and its view at resources/views/livewire/edit-user.blade.php:

use Salioudiabate\Modal\ModalComponent;

class EditUser extends ModalComponent
{
    public ?User $user = null;

    public function mount(User $user): void
    {
        $this->user = $user;
    }

    public function save(): void
    {
        $this->user->save();

        $this->closeModal();
    }

    public function render(): View
    {
        return view('livewire.edit-user');
    }
}
<x-modal::panel title="Edit user" description="Update this person's details.">
    <form wire:submit="save">
        <input type="text" wire:model="user.name">
    </form>

    <x-slot:footer>
        <x-modal::button variant="secondary" wire:click="closeModal">Cancel</x-modal::button>
        <x-modal::button variant="primary" wire:click="save">Save</x-modal::button>
    </x-slot:footer>
</x-modal::panel>

A public property meant to receive a route/model-bound argument needs a default value — public ?User $user = null;, not public User $user;. An uninitialized typed property is invisible to PHP's get_object_vars() until assigned, which is what the resolution step below inspects.

Opening a modal

From a Blade view, no Livewire action needed<x-modal::trigger> renders a <button> that dispatches the open event itself:

<x-modal::trigger component="edit-user" :arguments="['user' => $user->id]" class="modal-btn modal-btn-primary">
    Edit
</x-modal::trigger>

From inside a Livewire component, add InteractsWithModals:

use Salioudiabate\Modal\Concerns\InteractsWithModals;

class UserTable extends Component
{
    use InteractsWithModals;

    public function edit(User $user): void
    {
        $this->openModal(EditUser::class, ['user' => $user]);

        // or the fluent form, for anything openModal() can't reach:
        $this->modal(EditUser::class)
            ->with(['user' => $user])
            ->maxWidth('xl')
            ->show();
    }
}

From anywhere else during a Livewire request (once a component using InteractsWithModals has booted on the page), the plain facade auto-targets it — no ->forComponent($this) needed:

Modal::open(EditUser::class)->with(['user' => $user])->show();

Outside of a Livewire request entirely, dispatch the browser event directly (or use <x-modal::trigger> above):

<button onclick="Livewire.dispatch('modal-open', { name: 'edit-user', arguments: { user: 42 } })">
    Edit
</button>

PendingModal — every option

Modal::open(EditUser::class)
    ->with(['user' => $user])       // arguments passed to mount()
    ->maxWidth('xl')                 // xs..5xl, full, or a raw length: '480px', '60ch', '75vw'
    ->slideOver()                    // docks to the edge of the screen instead of centering
    ->closeOnClickAway(false)        // clicking the backdrop does nothing
    ->closeOnEscape(false)           // Escape does nothing
    ->forceCloseOnEscape(false)      // Escape pops back to the previous modal instead of closing the whole stack
    ->dispatchCloseEvent()           // fires "modal-closed" (name: ...) once this modal closes
    ->destroyOnClose()               // drops the component's server-side state on close, instead of keeping it for a possible re-open
    ->forComponent($this)            // explicit dispatch target (usually unnecessary — see above)
    ->show();                        // alias: ->open()

Every one of these can also be set as the component's own default, by overriding the matching static method on your ModalComponent subclass — PendingModal's setters only override it for that one call:

class EditUser extends ModalComponent
{
    public static function modalMaxWidth(): string { return 'xl'; }
    public static function slideOver(): bool { return true; }
    public static function closeOnClickAway(): bool { return false; }
    public static function closeOnEscape(): bool { return true; }
    public static function forceCloseOnEscape(): bool { return true; }
    public static function dispatchCloseEvent(): bool { return false; }
    public static function destroyOnClose(): bool { return false; }
}

Closing a modal

From inside the modal component itself:

$this->closeModal();

// or, dispatching one or more events first (page-wide, or scoped to a component):
$this->closeModalWithEvents([
    'user-updated',                          // page-wide
    'user-table' => 'refresh',                // scoped to the "user-table" component
    'user-table' => ['refresh', ['id' => 1]], // ...with parameters
]);
<x-modal::button wire:click="closeModal">Cancel</x-modal::button>

Nested modals (one opened from inside another) form a history: closing the top one returns to its parent instead of closing everything, unless you call $this->forceClose()->closeModal() first — or skip straight past one or more ancestors:

$this->skipPreviousModals(count: 1, destroy: true)->closeModal();

Route/model-bound arguments

Arguments resolve the same way route parameters do: pass a model's key instead of the instance itself, and a typed public property pulls the model in automatically via its resolveRouteBinding() (an Eloquent model works out of the box):

<x-modal::trigger component="edit-user" :arguments="['user' => $user->id]">Edit</x-modal::trigger>
class EditUser extends ModalComponent
{
    public ?User $user = null; // resolved from the "user" id above

    public function mount(User $user): void { $this->user = $user; }
}

A backed enum resolves the same way from its raw value, and an already-resolved instance (e.g. ['user' => $user], the model itself) is passed straight through untouched.

Working with custom dropdowns, selects, date pickers...

A select/combobox/date picker rendered inside a modal usually teleports its own floating panel to <body> (e.g. Alpine's <template x-teleport="body">) to escape the dialog's overflow-y: auto clipping. That also drops it outside the modal's focus trap — which makes it unreachable the instant the trap activates, since everything outside the trapped element becomes inert.

Modal handles this through a small event contract instead of trying to special-case any particular select library: any widget that opens its own floating panel inside a modal dispatches a bubbling modal-overlay-opened browser event when it opens, and modal-overlay-closed when it closes. Modal counts these and, while the count is above zero:

  • suspends its own focus trap, so the teleported panel stays focusable and clickable;
  • suspends Escape-to-close, so pressing Escape closes the nested panel first instead of the whole modal underneath it.

Wiring an Alpine-based combobox into this takes one line, typically on whatever boolean already tracks its own open state:

init() {
    this.$watch('isOpen', (value) => {
        this.$dispatch(value ? 'modal-overlay-opened' : 'modal-overlay-closed');
    });
},

Already have an existing convention with different event names? Point Modal at them instead of renaming every select component to match:

// config/modal.php
'dropdown_events' => [
    'opened' => 'select-search-opened',
    'closed' => 'select-search-closed',
],

For the floating panel itself: teleport it out of the modal's DOM so the dialog's overflow can't clip it, position it with getBoundingClientRect() (teleporting drops it out of normal document flow, so it needs position: fixed coordinates computed from its trigger), and give it a z-index at or above var(--modal-z) (9999 by default) so it stacks above the dialog:

<template x-teleport="body">
    <div :style="dropdownStyles" style="z-index: var(--modal-z, 9999);">...</div>
</template>

The UI kit

Three Blade components cover most dialog content — none of it requires Tailwind, all of it themes with modal.css's design tokens:

  • <x-modal::panel :title="" :description="" :closable="true"> — header, scrollable body (default slot), optional <x-slot:footer>. Renders its own close (×) button unless :closable="false".
  • <x-modal::button variant="primary|secondary|danger|ghost"> — themed action button; any other attribute (wire:click, type="submit", ...) passes straight through.
  • <x-modal::trigger component="..." :arguments="[...]"> — a plain <button> that opens a modal client-side with no Livewire action needed.

Bring your own markup for anything else — modal.css's classes (.modal-btn, .modal-panel__*, ...) are documented in the file itself if you want to match them by hand.

Customization

Nothing here is fixed — every layer can be overridden independently, from a single button's color up to replacing the stack container's own markup.

Colors, radii, shadows. Every visual token is a CSS custom property (--modal-*) in modal.css, overridable globally with a handful of lines — no rebuild, no SCSS variables:

:root {
  --modal-btn-primary-bg: #7c3aed;
  --modal-r-xl: 12px;
}

Color scheme. Follows prefers-color-scheme by default. Force it globally via config('modal.color_scheme'), or let the visitor choose at runtime — persists across reloads via localStorage:

Modal.setColorScheme('dark'); // 'light' | 'dark' | 'system'

Behavior, per component (ModalComponent's static methods) or per call (PendingModal's fluent setters) — width, slide-over vs. centered, click-away/Escape dismissal, close-event dispatching, state destruction on close. See "PendingModal — every option" above.

Any piece of markup. <x-modal::panel>/<x-modal::button>/<x-modal::trigger>, the root stack container, even <x-modal::root> itself are plain published views:

php artisan vendor:publish --tag=modal-views

edits land in resources/views/vendor/modal/... and take precedence over the package's own copies automatically — change a class name, add a slot, swap the close icon, rewrite the whole stack container's transitions, whatever the design calls for.

Or skip the UI kit entirely. <x-modal::panel> and friends are optional sugar — a ModalComponent renders whatever Blade view you give it in render(), so writing fully custom markup for a specific modal (or for the whole app, ignoring the kit altogether) works exactly the same as for any other Livewire component.

Every config default — width, dismissal behavior, color scheme, the dropdown event names above, whether assets are self-served — lives in one published file:

php artisan vendor:publish --tag=modal-config

Testing your own modals

use Livewire\Livewire;

Livewire::test(EditUser::class, ['user' => $user])
    ->call('save')
    ->assertDispatched('modal-close');

To assert a trigger opened the right thing from a host component:

Livewire::test(UserTable::class)
    ->call('edit', $user)
    ->assertDispatched('modal-open', name: EditUser::class, arguments: ['user' => $user->id]);

Security

See SECURITY.md. Report vulnerabilities privately rather than via a public issue.

License

MIT. See LICENSE.md.

Related Packages

kore-ui/kore-ui

A modern UI component library for Laravel with Livewire 4, Tailwind CSS v4, and...

100 0
maystro/filament-popup-modal

A comprehensive modal dialog system for FilamentPHP with progress bars, callback...

8 1
mrshanebarron/modal

Modal dialog component for Laravel - supports Livewire and Vue

13 0