salioudiabate/modal
| Install | |
|---|---|
composer require salioudiabate/modal |
|
| Latest Version: | v1.0.0 |
| PHP: | ^8.3 |
| License: | MIT |
| Last Updated: | Aug 21, 2026 |
| Links: | GitHub · Packagist |
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 — 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.
Related Packages
A modern UI component library for Laravel with Livewire 4, Tailwind CSS v4, and...
A comprehensive modal dialog system for FilamentPHP with progress bars, callback...
Gorgeous UI components for Livewire powered by daisyUI and Tailwind