<x-widget.button>
Button
Button or link in primary, secondary, tertiary, danger, neutral and link variants, with sizes, icons, icon-only buttons and a loading state. Guards forms against double submits. Includes a back link. Works inside Livewire components, wire:submit forms included.
php artisan larawell:add button
- Also adds
- Icon
- Used by
- Clock, Date range picker, Dropdown, Modal, Pagination, Table
Usage
In a form
A submit button guards against double submits: once its form submits, it shows its loading state until the next page arrives.
<form method="POST" action="{{ route('profile.update') }}">
@csrf
{{-- your fields --}}
<x-widget.button type="submit">Save changes</x-widget.button>
{{-- Opt out where the page stays open after submitting, such as a file download. --}}
<x-widget.button type="submit" formaction="{{ route('profile.export') }}" :submit-guard="false" variant="neutral">Export CSV</x-widget.button>
</form>
Livewire
In a wire:submit form, Livewire sends the form itself and holds the submit button until the answer comes back: the button shows its loading state meanwhile, keeps its colour, and gets focus back afterwards if it had it. A wire:click button shows it with wire:loading.attr="aria-busy" (and wire:target, so other requests don't set it off); a quick toggle is better without. :loading set from a property works too.
<form wire:submit="save" class="space-y-5">
{{-- your fields --}}
<x-widget.button type="submit">Save changes</x-widget.button>
</form>
<x-widget.button variant="neutral" wire:click="export" wire:loading.attr="aria-busy" wire:target="export">Export CSV</x-widget.button>
<x-widget.button variant="danger" :loading="$deleting" wire:click="delete">Delete</x-widget.button>
Examples
Variants
danger is for destructive actions; neutral is the quiet choice next to a primary one, like Cancel beside Save.
Show code Hide code
<div class="flex flex-wrap items-center gap-3">
<x-widget.button>Primary</x-widget.button>
<x-widget.button variant="secondary">Secondary</x-widget.button>
<x-widget.button variant="tertiary">Tertiary</x-widget.button>
<x-widget.button variant="danger">Delete</x-widget.button>
<x-widget.button variant="neutral">Cancel</x-widget.button>
<x-widget.button variant="link">Link</x-widget.button>
</div>
Sizes and states
With href the button renders as a link. Loading and disabled both block clicks; while loading, the spinner takes the icon's place.
Show code Hide code
<div class="flex flex-wrap items-center gap-3">
<x-widget.button size="sm">Small</x-widget.button>
<x-widget.button size="lg">Large</x-widget.button>
<x-widget.button icon-start="plus">Add wallet</x-widget.button>
<x-widget.button variant="secondary" icon-start="check" loading>Saving</x-widget.button>
<x-widget.button variant="secondary" disabled>Disabled</x-widget.button>
<x-widget.button variant="tertiary" href="#" icon-end="arrow-right">Continue</x-widget.button>
</div>
Icon only
icon with no text makes a square button. label is required: it is the button's name for screen readers, and its tooltip.
Show code Hide code
<div class="flex flex-wrap items-center gap-3">
<x-widget.button icon="plus" label="Add wallet" />
<x-widget.button icon="search" label="Search" variant="secondary" />
<x-widget.button icon="x" label="Close" variant="neutral" />
<x-widget.button icon="eye" label="Show details" variant="tertiary" size="sm" />
<x-widget.button icon="refresh-cw" label="Refresh" variant="neutral" size="lg" />
</div>
Back
Show code Hide code
<x-widget.button.back label="Back to wallets" href="#" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.button>
| Prop | Default | Description |
|---|---|---|
| variant |
'primary'
|
|
| size |
'md'
|
|
| type |
'button'
|
|
| href |
null
|
|
| loading |
false
|
|
| disabled |
false
|
|
| icon-start |
null
|
|
| icon-end |
null
|
|
| icon |
null
|
|
| label |
null
|
|
| submit-guard |
true
|
<x-widget.button.back>
| Prop | Default | Description |
|---|---|---|
| href |
null
|
|
| label |
null
|
Source
What larawell:add button writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/button/back.blade.php Show
@props([
'href' => null,
'label' => null,
])
<a
href="{{ $href ?? url()->previous() }}"
{{ $attributes->class(['text-foreground focus-visible:ring-primary inline-flex max-w-fit items-center gap-5 rounded-md text-sm outline-none hover:opacity-80 focus-visible:ring-2']) }}
>
{{-- Points back, which is right in right-to-left pages. --}}
<x-widget.icon name="arrow-left" class="size-4 rtl:-scale-x-100" />
@if ($label)
<span>{{ $label }}</span>
@else
<span class="sr-only">Back</span>
@endif
</a><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/button/index.blade.php Show
@props([
'variant' => 'primary',
'size' => 'md',
'type' => 'button',
'href' => null,
'loading' => false,
'disabled' => false,
'iconStart' => null,
'iconEnd' => null,
'icon' => null,
'label' => null,
'submitGuard' => true,
])
@php
// Disabled looks are skipped while loading (aria-busy), so a loading button keeps its colour.
$variants = [
'primary' => 'bg-primary text-on-primary border-transparent not-disabled:hover:bg-primary-hover disabled:not-aria-busy:bg-line disabled:not-aria-busy:text-muted',
'secondary' => 'bg-primary/10 text-primary border-transparent not-disabled:hover:bg-primary-hover not-disabled:hover:text-on-primary disabled:not-aria-busy:bg-field disabled:not-aria-busy:text-muted',
'tertiary' => 'bg-transparent text-primary border-primary/40 not-disabled:hover:border-primary-hover not-disabled:hover:bg-primary-hover not-disabled:hover:text-on-primary disabled:not-aria-busy:border-line disabled:not-aria-busy:text-muted',
// For destructive actions: delete, remove, cancel a subscription.
'danger' => 'bg-error border-transparent text-white not-disabled:hover:brightness-90 disabled:not-aria-busy:bg-line disabled:not-aria-busy:text-muted',
// The quiet choice next to a primary action, e.g. Cancel beside Save.
'neutral' => 'bg-field text-foreground border-transparent not-disabled:hover:bg-line disabled:not-aria-busy:text-muted',
'link' => 'bg-transparent text-link border-transparent not-disabled:hover:text-link-hover disabled:not-aria-busy:text-muted',
];
// Text and padding are separate so the link variant can keep the text size without the padding.
$textSizes = ['sm' => 'text-xs', 'md' => 'text-sm', 'lg' => 'text-base'];
$paddings = ['sm' => 'px-3 py-2', 'md' => 'px-4 py-3', 'lg' => 'px-6 py-3.5'];
// Icon-only buttons are square and exactly as tall as a text button of the same size.
$squarePaddings = ['sm' => 'p-2', 'md' => 'p-3.5', 'lg' => 'p-4'];
$variant = array_key_exists($variant, $variants) ? $variant : 'primary';
$size = array_key_exists($size, $textSizes) ? $size : 'md';
// An icon with no visible text still needs a name for screen readers; fail in development rather than ship a silent button.
$iconOnly = $icon !== null;
if ($iconOnly && ($label === null || trim($label) === '')) {
throw new \InvalidArgumentException("<x-widget.button icon=\"{$icon}\"> needs a label, e.g. label=\"Close\": it is the button's only name for screen readers.");
}
$classes = [
'group/button relative inline-flex min-w-fit items-center justify-center gap-1.5 border font-medium whitespace-nowrap select-none transition-all outline-none',
'focus-visible:ring-primary focus-visible:ring-2 focus-visible:ring-offset-2',
// A guarded (busy) button is aria-disabled, not disabled, so it keeps focus; it ignores the pointer instead.
'not-disabled:active:scale-95 disabled:cursor-not-allowed aria-busy:cursor-wait aria-busy:pointer-events-none',
$variants[$variant],
$textSizes[$size],
match (true) {
$variant === 'link' => 'rounded-md py-1',
$iconOnly => 'rounded-xl '.$squarePaddings[$size],
default => 'rounded-xl '.$paddings[$size],
},
];
// A disabled or loading link renders as a disabled <button>: <a> has no real disabled state.
$isLink = $href && ! $disabled && ! $loading;
$iconClass = $size === 'lg' ? 'size-5' : 'size-4';
// The spinner takes the place of the first icon while busy, so the button doesn't change width.
// It is always rendered and shown by aria-busy, so resources/js/widget/button can switch it on too.
$spinnerSlot = $iconOnly || $iconStart ? 'start' : 'end';
$whileBusy = 'group-aria-busy/button:hidden';
@endphp
<{{ $isLink ? 'a' : 'button' }}
data-button
@if ($isLink)
href="{{ $href }}"
@else
type="{{ $type }}"
@disabled($disabled || $loading)
@if ($loading) aria-busy="true" @endif
@if ($type === 'submit' && $submitGuard) data-submit-guard @endif
@endif
@if ($iconOnly) aria-label="{{ $label }}" title="{{ $label }}" @endif
{{ $attributes->class($classes) }}
>
@if ($spinnerSlot === 'start' && ! $isLink)
<span class="hidden size-4 animate-spin rounded-full border-2 border-current border-r-transparent group-aria-busy/button:inline-block" aria-hidden="true"></span>
@endif
@if ($iconOnly)
<x-widget.icon :name="$icon" :class="$iconClass.' '.$whileBusy" />
@else
@if ($iconStart)
<x-widget.icon :name="$iconStart" :class="$iconClass.' '.$whileBusy" />
@endif
{{ $slot }}
@if ($iconEnd)
<x-widget.icon :name="$iconEnd" :class="$spinnerSlot === 'end' ? $iconClass.' '.$whileBusy : $iconClass" />
@endif
@endif
@if ($spinnerSlot === 'end' && ! $isLink)
<span class="hidden size-4 animate-spin rounded-full border-2 border-current border-r-transparent group-aria-busy/button:inline-block" aria-hidden="true"></span>
@endif
@if (! $isLink)
<span class="sr-only hidden group-aria-busy/button:inline">Loading</span>
@endif
</{{ $isLink ? 'a' : 'button' }}><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/button/index.js Show
// Double-submit protection for <x-widget.button type="submit">: once its form submits, the button shows
// its loading state, so a slow request can't be sent twice. Opt out with :submit-guard="false".
// A form that downloads a file (or otherwise never leaves the page) would keep its button busy for good;
// after this long with the page still showing, the guard lets go.
const RELEASE_AFTER_MS = 15_000;
function release(button) {
delete button.dataset.guarded;
button.removeAttribute('aria-busy');
button.removeAttribute('aria-disabled');
if (button.dataset.idleLabel !== undefined) {
button.setAttribute('aria-label', button.dataset.idleLabel);
delete button.dataset.idleLabel;
}
}
// aria-disabled rather than disabled: a disabled button loses keyboard focus to <body>, and screen readers then hear
// nothing. The submit listener below blocks a second submit instead.
function busy(button) {
button.dataset.guarded = '';
button.setAttribute('aria-busy', 'true');
button.setAttribute('aria-disabled', 'true');
// An icon-only button's aria-label hides its "Loading" text, so say it in the label for now.
const label = button.getAttribute('aria-label');
const loading = button.querySelector('.sr-only')?.textContent.trim();
if (label && loading) {
button.dataset.idleLabel = label;
button.setAttribute('aria-label', `${label}, ${loading.toLowerCase()}`);
}
}
// What had focus as the submit began, before anything else could disable the button (see wire:submit below).
let focusedAtSubmit = null;
// While a form's button is busy, a second submit (Enter in a field, a double click) goes nowhere.
document.addEventListener('submit', (event) => {
focusedAtSubmit = document.activeElement;
if (event.target.querySelector?.('[data-button][data-guarded]')) {
event.preventDefault();
}
}, true);
// wire:submit: Livewire cancels the browser's submit, sends the form itself and disables the button until the answer
// comes back, which turns it grey and drops focus to <body>. Show the loading state instead, and when Livewire enables
// the button again, let go and give focus back if the button had it.
function guardLivewire(button) {
const hadFocus = focusedAtSubmit === button;
busy(button);
const watch = new MutationObserver(() => {
if (button.disabled) {
return;
}
watch.disconnect();
release(button);
if (hadFocus && (document.activeElement === document.body || document.activeElement === null)) {
button.focus();
}
});
watch.observe(button, { attributes: true, attributeFilter: ['disabled'] });
}
const livewireSubmit = (form) => Boolean(form.closest('[wire\\:id]')) && [...form.attributes].some((attribute) => attribute.name.startsWith('wire:submit'));
document.addEventListener('submit', (event) => {
const button = event.submitter;
const form = event.target;
if (!button?.matches('[data-submit-guard]')) {
return;
}
// A form that opens somewhere else (target="_blank") leaves this page as it is, so there's nothing to guard.
const target = button.getAttribute('formtarget') ?? form.getAttribute('target');
if (target && target !== '_self') {
return;
}
// Decided on the next tick, after every other submit handler has run: one registered later (or on window)
// may still cancel the submit, and then the button must stay as it was.
setTimeout(() => {
if (!button.isConnected) {
return;
}
if (event.defaultPrevented) {
// Only while Livewire holds the button disabled: that's what says when its answer is in.
if (livewireSubmit(form) && button.disabled) {
guardLivewire(button);
}
return;
}
busy(button);
setTimeout(() => {
if (button.isConnected && 'guarded' in button.dataset && document.visibilityState === 'visible') {
release(button);
}
}, RELEASE_AFTER_MS);
});
});
// Coming back with the Back button can restore the page as it was left, busy button included.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) {
return;
}
document.querySelectorAll('[data-button][data-guarded]').forEach(release);
});