Skip to content
Menu

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&hellip;</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&hellip;</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>