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-xsforbtn-xs/btn-sm,loading-smfor the default size,loading-mdforbtn-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.