Skip to content
LarawellUi

<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.

Blade
<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.

Blade
<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
Blade
<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.

Continue
Show code
Blade
<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
Blade
<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
Blade
<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
back.blade.php
@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
index.blade.php
@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
index.js
// 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);
});