Loading Button

Button with integrated loading feedback — spinner, progress fill, and success/error state swap.

Spinner — Replaces Label

The label hides when loading begins. Button width stays fixed to prevent layout shift.

Click any button to trigger the loading state

<button class="btn btn-primary min-w-[9rem]"
        x-data="{ loading: false }"
        :disabled="loading"
        @click="loading = true; setTimeout(() => loading = false, 2500)">
    <span x-show="loading" class="loading loading-spinner loading-sm"></span>
    <span x-show="!loading">Save changes</span>
</button>

<!-- min-w fixes button width so layout doesn't shift when label swaps out -->

Spinner — Beside Label

The label stays visible during loading, paired with a spinner for continuous operations where users benefit from seeing what's happening.

Label text also changes to reflect the in-progress action

<button class="btn btn-primary gap-2"
        x-data="{ loading: false }"
        :disabled="loading"
        @click="loading = true; setTimeout(() => loading = false, 2500)">
    <span x-show="loading" class="loading loading-spinner loading-xs"></span>
    <span x-text="loading ? 'Saving…' : 'Save draft'"></span>
</button>

Success / Error State Swap

After the async operation resolves, swap the button into a confirmation or error state briefly before resetting. Keeps users informed without a toast notification.

Click each button — "Publish post" randomly succeeds or fails to demonstrate both paths

<button class="btn min-w-[10rem] gap-2"
        x-data="{ state: 'idle' }"
        :disabled="state === 'loading'"
        :class="{
            'btn-primary': state === 'idle' || state === 'loading',
            'btn-success': state === 'success',
            'btn-error':   state === 'error'
        }"
        @click="
            if (state !== 'idle') return;
            state = 'loading';
            yourApiCall()
                .then(() => {
                    state = 'success';
                    setTimeout(() => state = 'idle', 2200);
                })
                .catch(() => {
                    state = 'error';
                    setTimeout(() => state = 'idle', 2200);
                })
        ">
    <span x-show="state === 'loading'" class="loading loading-spinner loading-sm"></span>
    <i data-lucide="check"        style="width:16px;height:16px;" x-show="state === 'success'"></i>
    <i data-lucide="alert-circle" style="width:15px;height:15px;" x-show="state === 'error'"></i>
    <span x-text="{
        idle:    'Publish post',
        loading: 'Publishing…',
        success: 'Published!',
        error:   'Publish failed'
    }[state]"></span>
</button>

Progress Bar Fill

For uploads or multi-step operations where you have real progress data. The bar is a position:absolute fill layered inside the button — no custom CSS needed beyond an inline width binding.

Click to start — progress increments are randomised to simulate real upload chunks

<!-- Variant A: fill inside the button -->
<button class="btn btn-primary relative overflow-hidden min-w-[10rem]"
        x-data="{ progress: 0, loading: false }" :disabled="loading" @click="start()">
    <span class="absolute inset-0 bg-white/20 transition-all duration-200 origin-left"
          :style="'width:' + progress + '%'" x-show="loading"></span>
    <span class="relative z-10"
          x-text="loading ? 'Uploading ' + Math.round(progress) + '%' : 'Upload file'"></span>
</button>

<!-- Variant B: DaisyUI progress bar below the button -->
<button class="btn btn-outline" :disabled="loading" @click="start()">...</button>
<progress class="progress progress-primary w-40"
          x-show="loading" :value="progress" max="100"></progress>

Skeleton Pulse

Use when the button itself is waiting on data before it can be labelled — e.g. a price that loads async, or a CTA that depends on user state.

Skeletons pulse for ~2.5 s then resolve into the real buttons

<div x-data="{ ready: false }" x-init="fetchData().then(() => ready = true)">

    <!-- Skeleton placeholder (same dimensions as the real button) -->
    <div x-show="!ready"
         class="skeleton h-10 w-36 rounded-[var(--radius-sm)]"></div>

    <!-- Real button, fades in when data is ready -->
    <button x-show="ready" x-cloak x-transition
            class="btn btn-primary gap-2">
        <i data-lucide="shopping-cart" style="width:15px;height:15px;"></i>
        Add to cart — $29
    </button>

</div>

Size Matrix

xs · sm · md · lg — match loading-{size} to btn-{size}

<button class="btn btn-primary btn-xs gap-1.5" disabled>
    <span class="loading loading-spinner loading-xs"></span> Loading
</button>
<button class="btn btn-primary btn-sm gap-2" disabled>
    <span class="loading loading-spinner loading-xs"></span> Loading
</button>
<button class="btn btn-primary gap-2" disabled>
    <span class="loading loading-spinner loading-sm"></span> Loading
</button>
<button class="btn btn-primary btn-lg gap-2" disabled>
    <span class="loading loading-spinner loading-md"></span> Loading
</button>

Usage Notes

  • Always set :disabled="loading" — prevents double-submit and removes the button from the tab order during the operation.
  • When the label swaps out for a spinner, use min-w-[…] to pin the button width. Without it, the button shrinks to fit the spinner and causes a layout shift.
  • Match spinner size to button size: loading-xs for btn-xs/btn-sm, loading-sm for the default size, loading-md for btn-lg.
  • The success/error state swap is a supplement to, not a replacement for, form validation feedback. Use it for top-level async actions (publish, send, delete), not inline field errors.
  • Use the skeleton variant only when the button's label or availability depends on async data — not as a general loading state while a form is submitting.