A modal is an async question, so it should be awaitable
This is me
Latest articles
- GDPR cookie consent in Laravel with Wirecookies (opens on another site)
- Localized routes in Laravel with Laralang (opens on another site)
- Collecting user feedback in Laravel with Wirebug (opens on another site)
- The same operation ends up in three places, and none of them agree
- Laravel makes you choose between readable templates and stable translation keys
- Model observers are the wrong home for derived fields
- A coding agent is a generalist, and your codebase is not general
- Translating a Laravel app is two problems, and only one of them is about words
- The tags table is simple until the fourth requirement arrives
Every project needs a modal in the first week. Delete confirmation, edit dialog, unsaved-changes prompt.
And every project rewrites it, because the modal is never built as a component; it is built as state. An Alpine x-data="{ open: false }" in one place, a Livewire boolean in another, a jQuery leftover in the admin. Three implementations that look different, behave differently on ESC, and cannot be reused because each is welded to the state container that happened to be nearby.
The framing error is thinking a modal is a thing that is open or closed. A confirmation modal is a question, and a question has an answer. Once you model it that way, most of the design decides itself.
Open returns a Promise
window.confirm has one genuinely good property: it reads like what it does.
if (confirm('Delete this?')) { ... }
The call site expresses the entire flow in one line. What is wrong with it is everything else: it blocks the main thread, you cannot style it, it looks like 1998.
So open() returns a Promise that resolves with whatever close() was given:
const result = await Wiremodal.open('confirm-delete');
if (result === 'confirmed') {
await deleteRecord();
}
Same shape as confirm, none of the drawbacks. The mechanism is deliberately small: a pendingResolvers map keyed by modal name holds the resolve function from open(), and close(name, result) looks it up and calls it. Keying by name rather than by element is what lets the opener and the closer be in completely different places (a Blade button, a Livewire method, an Alpine handler) without holding a reference to each other.
The alternative was callbacks (open('x', { onConfirm, onCancel })), which works and which I have written several times. It puts the consequence of the answer somewhere other than the question, and the nesting compounds the moment one confirmation leads to another.
Dismissing (the X, the overlay, ESC) resolves with undefined rather than rejecting. A rejection would make every call site need a try/catch to handle the most ordinary outcome there is, which would be treating "the user said no" as an error.
Being cancelable is not the same as being closable
The other feature I use constantly came from nearly shipping a bug: an edit modal where a stray overlay click discarded a half-typed form. No confirmation, no undo, just gone.
The fix is that closing is a request, not a command. wiremodal:beforeclose fires first and can be prevented:
modal.addEventListener('wiremodal:beforeclose', e => {
if (formIsDirty) {
e.preventDefault();
}
});
The detail that makes it usable is that the event carries a reason: dismiss for the X or overlay, escape for the key, programmatic for an explicit call. Without it you can only block all closes or none, and then a successful save cannot close its own modal without fighting your own guard. With it, you block the accidental closes and leave the intentional ones alone, which is what everybody actually wants and nobody can express with a boolean.
No config file, on purpose
The package ships no config/ directory at all. That is a decision, not an omission.
A config file for a UI component is a second place where appearance lives, and it is the worst of the available places: a designer cannot touch it, it is not visible from the markup, and it needs a cache clear. So theming is one attribute:
<html data-wire-theme="claude" data-wire-theme-mode="dark">
and the escape hatch below the eleven presets is CSS variables: shared --wire-* for the whole family, modal-specific --wm-* for finer control. Anything you would have configured is either a component prop, where you can see it at the call site, or a CSS variable, where a designer can reach it.
The same reasoning produced the zero-dependency rule. Wiremodal works with or without Livewire, with or without Alpine, because the controller is vanilla JS and the Livewire macros are a thin layer over it. A modal package that requires your state library is a modal package you cannot use on the marketing page.
What came out of it
One modal system across every project, matching my toasts and consent banner for free because they read the same tokens, and confirmation flows that read as one line six months later.
Component API, the Promise contract, events and theming are on the Wiremodal page. Source on GitHub, package on Packagist.