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.
Requires
- php: ^8.3
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- livewire/livewire: ^3.0|^4.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
README
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;, notpublic User $user;. An uninitialized typed property is invisible to PHP'sget_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.