andisiahaan/livewire-dialog
A Laravel Livewire package for building accessible, customizable modal dialogs with minimal JavaScript. It simplifies opening, closing, and managing dialog state directly from Livewire components.
This package is a thin wrapper, not a framework. The entire public surface is a Blade component, a state toggle, and event listeners. You can be productive in ten minutes; the risk is not onboarding, it's longevity.
composer require andisiahaan/livewire-dialog
There is no publishable config or migration mentioned in the description, so treat install as a no-op beyond autoloading. Confirm that assumption first:
composer show andisiahaan/livewire-dialog # version, requires, autoload
ls vendor/andisiahaan/livewire-dialog/resources/views
Check composer.json for the livewire/livewire constraint. The <livewire:dialog> tag syntax is Livewire 3's namespaced-component form; if your app is still on Livewire 2, stop here — there is no fallback syntax documented and you would be the one writing it.
vendor/andisiahaan/livewire-dialog/resources/views/** — the actual component markup. This tells you the prop names (:open, wire:model, name, maxWidth, whatever they chose), the z-index, and whether content is rendered inline or teleported. The description does not enumerate props; this file does.src/ — the anonymous or class-based component and any traits. You need the exact method names (open(), close(), toggle()?) and the event names it listens for, because the description only promises "simple methods."README on the repo for a Tailwind version assumption (Alpine directives inside usually imply Tailwind classes; check for a @tailwindcss/forms or Alpine dependency).Do this before anything ambitious. It exercises the whole contract — state, slot, dismissal, method call.
// app/Livewire/ManagePosts.php
class ManagePosts extends Component
{
public bool $confirmingDelete = false;
public ?int $deletingId = null;
public function confirm(int $id): void { $this->deletingId = $id; $this->confirmingDelete = true; }
public function cancel(): void { $this->confirmingDelete = false; $this->deletingId = null; }
public function delete(): void { /* authorize + delete */ $this->cancel(); }
public function render() { return view('livewire.manage-posts'); }
}
<livewire:dialog :open="$confirmingDelete">
<h2>Delete this post?</h2>
<p>This cannot be undone.</p>
<button wire:click="cancel">Cancel</button>
<button wire:click="delete">Delete</button>
</livewire:dialog>
If that renders, animates, closes on Escape, and closing sets $confirmingDelete = false on the server, you have validated the package's core claim. If closing only hides the DOM and leaves your state true, you have found the single most important thing to know before writing forty dialogs — see Gotchas.
Because visibility is server state, the naming discipline is the architecture:
public bool $showInviteDialog = false;
public bool $showImportDialog = false;
Avoid a single $activeDialog string unless you enjoy @if chains inside slots. Separate booleans keep each dialog's data ($inviteEmail, $importFile) independent and make Livewire::test() assertions trivial.
The highest-value use is gating a destructive or irreversible action behind a second round-trip. The pattern above is the whole thing. Two refinements that matter in production:
confirm(). The dialog may sit open for minutes.delete(); don't trust $deletingId alone.This is where server-driven dialogs beat JS modals: validation failures land naturally.
<livewire:dialog :open="$showCreateDialog">
<form wire:submit="save">
<input type="text" wire:model="title">
@error('title') <span>{{ $message }}</span> @enderror
<button type="submit" wire:loading.attr="disabled" wire:target="save">Save</button>
</form>
</livewire:dialog>
Key rule: the form lives inside the slot, so its markup is part of the parent component's template. Errors, wire:loading, and wire:target all resolve against the parent component — no nested component, no event plumbing, no $parent. This is the package's real selling point over hand-rolled Alpine modals.
Close on success ($this->showCreateDialog = false; at the end of save()), stay open on failure (leave it true). That is the entire UX contract users expect.
Pair dialogs with wire:loading.disable on the confirming button and keep the dialog open until the job resolves. If the work is slow, prefer dispatching a queued job and closing immediately with a flash message — a spinner inside a modal for thirty seconds is worse than no modal.
Because the package owns the overlay per instance, stacking two open dialogs is a z-index and scroll-lock problem you don't want. Enforce "at most one open" by making the open props mutually exclusive at the method level:
private function closeAll(): void { $this->showInviteDialog = $this->showImportDialog = false; }
Server-driven dialogs are testable without a browser, which is the main TCO argument for choosing this over a JS library:
Livewire::test(ManagePosts::class)
->call('confirm', 5)
->assertSet('confirmingDelete', true)
->assertSee('Delete this post?')
->call('delete')
->assertSet('confirmingDelete', false);
Note that assertSee passes whether or not the dialog is visually open, depending on whether the package uses @if or CSS classes. Test the state, and separately assert the rendered markup toggles (assertSeeHtml / assertDontSeeHtml) if visibility is @if-gated.
Given the signals in this package's profile, the cheapest insurance is a one-file indirection. Create resources/views/components/app-dialog.blade.php that renders <livewire:dialog {{ $attributes }}>{{ $slot }}</livewire:dialog>, and use <x-app-dialog> throughout your app. Swapping to Flux, Filament, or a 60-line Alpine component later becomes a single-file change instead of a grep-and-replace across every view.
"Escape-key and click-outside dismissal" describes client-side behaviour. The critical question is whether dismissal dispatches back to Livewire (e.g. $wire.set('open', false) or $dispatch('dialog-closed')) or merely hides the element. If it only hides, your $showDialog stays true, and the next render reopens the dialog — a bug that reproduces intermittently and blames the framework. Test Escape explicitly with Dusk or a quick manual pass on day one. If it doesn't sync, add x-on:close.window="$wire.set('showX', false)" or a close() method on the package component.
Unless the package uses x-teleport, the dialog markup renders where you put it. Inside a <table> row, inside a position: relative container with overflow: hidden, or inside an element with a transform, your "fixed" overlay will be clipped or mispositioned. If the package doesn't teleport, wrap the component in x-teleport="body" yourself (Alpine ships with Livewire).
aria-modal are claims, not guaranteesThe description advertises "accessibility details." Zero-star packages rarely ship a complete focus trap (first-focus, tab cycling, focus restoration on close, inert on the background). Audit it: open the dialog, press Tab repeatedly, and check whether focus escapes to the page behind. If it does, add x-trap from @focus-trap/alpine or accept the gap knowingly. Do not put a compliance claim in a ticket based on the README.
A common failure mode is document.body.style.overflow = 'hidden' set on open and never cleared when the component is removed by a Livewire morph or wire:navigate. Symptom: the page inexplicably stops scrolling after navigation. Check the Alpine x-init/destroy pairing before shipping.
Dragging to select text inside the dialog and releasing outside fires the click-outside handler. If it's wired to mousedown rather than click paired with a guard, users will lose their typed content. Reproduce this deliberately; if broken, disable outside-dismiss (:close-on-click-outside="false" if available) rather than fight it.
If you already have a command palette, a select dropdown, or an Alpine x-on:keydown.escape handler, the dialog's Escape listener will race it. Scope it (x-on:keydown.escape.window with a "topmost dialog" guard) or you'll get both closing at once.
If the package ships its views inline, you cannot restyle markup without publishing or overriding a vendor view. Check for a --tag= publish option. If there is none, your only lever is CSS overrides on the classes it emits — brittle, and the reason the wrapper component in Pattern 7 is worth the ten minutes.
wire:key matters on repeated dialogsRendering a dialog per row in a @foreach (:open="$confirmingId === $post->id") works, but without a unique wire:key Livewire's morph can reuse a dialog's DOM and leave stale slot content from a different row. Prefer one dialog per component with a $targetId property — cleaner and immune to this class of bug.
$this->resetValidation() on open is usually necessary; otherwise the red text from the last attempt greets the user before they type. Same for $this->reset('form') if the dialog should open blank.
Zero stars, zero dependents, and a single publish date tell you the bus factor is one and the integration surface is undocumented beyond a paragraph. Concretely:
"andisiahaan/livewire-dialog": "1.0.*") and commit the lock file.vendor/).<flux:modal>, Filament's modal for panel apps, or ~40 lines of Alpine + x-trap. All three are drop-in replacements behind the wrapper.If you need anything beyond open/close — draggable, resizable, stacked sheets, nested confirms, or slide-over variants — this package will cost you more in workarounds than the Alpine component it wraps would cost to write. Its honest scope is the simple confirmation and the form-in-a-box, and it does those well precisely because it does nothing else.
How can I help you explore Laravel packages today?