Experiment #010 · New
Livewire Loading Button
The canonical wire:loading pattern, built entirely from directives on plain markup. While the save action is in flight, wire:loading.attr="disabled" locks the button, wire:loading.class dims it, and a wire:loading / wire:loading.remove pair reveals an inline SVG spinner and swaps Save for Saving…. wire:target scopes all of it to the save action, so other requests from the same component leave the button untouched.
Scene
Technical details
- Experiment
- FX-010
- Category
- Livewire
- Stack
- Livewire + Tailwind
- Difficulty
- Beginner
- Dependencies
- none
- Weight
- HTML 2.6 KB · LIVEWIRE 3.0 KB
- Added
- 2026-08-29
- Updated
- 2026-08-29
Lab notes
Always pair every wire:loading directive with wire:target="save" — without it, any request from the component (a wire:model update, polling, another button) flips the loading state too. Keep the spinner inside the button so the layout barely shifts, and leave the transition on opacity only.
Take the code
HTML — code.html
<!-- Livewire Loading Button — Livewire + Tailwind -->
<!--
The whole effect is four wire:loading directives on plain markup.
Livewire toggles them while the targeted action is in flight:
wire:loading.attr="disabled" adds disabled during the request
wire:loading.class="..." adds classes during the request
wire:loading shows the element during the request
wire:loading.remove hides the element during the request
wire:target="save" scopes each directive to the save() action.
Without it, ANY request from the component (a wire:model update,
polling, another button) would flip the loading state too.
-->
<form wire:submit="save">
<button
type="submit"
wire:loading.attr="disabled"
wire:loading.class="cursor-wait opacity-75"
wire:target="save"
class="inline-flex items-center gap-2 rounded-sm bg-zinc-900 px-6 py-2.5
text-sm font-medium text-white transition-opacity duration-150
hover:bg-zinc-700 disabled:hover:bg-zinc-900
motion-reduce:transition-none"
>
<!-- Spinner — hidden at rest, shown while save() runs -->
<svg
wire:loading
wire:target="save"
class="size-4 animate-spin motion-reduce:animate-none"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<!-- Label swap — Save at rest, Saving… in flight -->
<span wire:loading.remove wire:target="save">Save</span>
<span wire:loading wire:target="save">Saving…</span>
</button>
<!-- Transient confirmation — driven by the 'saved' event save() dispatches -->
<p
x-data="{ shown: false, timer: null }"
x-on:saved.window="shown = true; clearTimeout(timer); timer = setTimeout(() => shown = false, 2000)"
x-show="shown"
x-cloak
role="status"
class="flex items-center gap-1.5 text-sm text-emerald-600"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Saved
</p>
</form>
Livewire — livewire.php
<?php
/*
* Livewire Loading Button — Livewire 4 single-file component.
*
* Save as resources/views/livewire/save-form.blade.php
* and render anywhere with <livewire:save-form />.
*/
use Livewire\Component;
new class extends Component
{
public string $name = '';
public function save(): void
{
$this->validate([
'name' => ['required', 'string', 'max:100'],
]);
sleep(1); // Stand-in for the real work: an update(), an API call…
$this->dispatch('saved');
}
};
?>
<form wire:submit="save" class="max-w-sm space-y-4">
<div class="space-y-1">
<label for="name" class="block text-sm font-medium text-zinc-900">Name</label>
<input
id="name"
type="text"
wire:model="name"
class="block w-full rounded-sm border border-zinc-300 px-3 py-2 text-sm
focus:border-zinc-500 focus:outline-none"
/>
@error('name')
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button
type="submit"
wire:loading.attr="disabled"
wire:loading.class="cursor-wait opacity-75"
wire:target="save"
class="inline-flex items-center gap-2 rounded-sm bg-zinc-900 px-6 py-2.5
text-sm font-medium text-white transition-opacity duration-150
hover:bg-zinc-700 disabled:hover:bg-zinc-900
motion-reduce:transition-none"
>
{{-- Spinner — hidden at rest, shown while save() runs --}}
<svg
wire:loading
wire:target="save"
class="size-4 animate-spin motion-reduce:animate-none"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{-- Label swap — Save at rest, Saving… in flight --}}
<span wire:loading.remove wire:target="save">Save</span>
<span wire:loading wire:target="save">Saving…</span>
</button>
{{-- Transient confirmation — driven by the 'saved' event save() dispatches --}}
<p
x-data="{ shown: false, timer: null }"
x-on:saved.window="shown = true; clearTimeout(timer); timer = setTimeout(() => shown = false, 2000)"
x-show="shown"
x-cloak
role="status"
class="flex items-center gap-1.5 text-sm text-emerald-600"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Saved
</p>
</form>