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' primary, secondary, tertiary (outlined), danger (destructive actions), neutral (the quiet choice, e.g. Cancel beside Save) or link (looks like a text link).
size 'md' sm, md or lg.
type 'button' The button's type: button, submit or reset. Ignored with href.
href null Makes it a link to this URL. While disabled or loading it renders as a disabled button instead.
loading false Busy: a spinner takes the place of the first icon and it can't be pressed. Screen readers hear "Loading".
disabled false Greyed out: it can't be pressed.
icon-start null An icon before the text.
icon-end null An icon after the text.
icon null An icon-only, square button: shows just this icon, and needs a label.
label null With icon: the button's name for screen readers and its tooltip. Required there.
submit-guard true type="submit": once its form submits, the button shows as loading so the form can't be sent twice. false turns it off.

<x-widget.button.back>

Prop Default Description
href null Where it goes. Defaults to the previous page.
label null Text beside the arrow. Without it, just the arrow shows, named "Back" for screen readers.

Accessibility

All 4 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

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([
    // Where it goes. Defaults to the previous page.
    'href' => null,
    // Text beside the arrow. Without it, just the arrow shows, named "Back" for screen readers.
    'label' => null,
])

<a
    href="{{ $href ?? url()->previous() }}"
    {{ $attributes->class(['text-foreground focus-visible:ring-primary inline-flex max-w-fit items-center gap-1.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([
    // primary, secondary, tertiary (outlined), danger (destructive actions), neutral (the quiet choice, e.g. Cancel
    // beside Save) or link (looks like a text link).
    'variant' => 'primary',
    // sm, md or lg.
    'size' => 'md',
    // The button's type: button, submit or reset. Ignored with href.
    'type' => 'button',
    // Makes it a link to this URL. While disabled or loading it renders as a disabled button instead.
    'href' => null,
    // Busy: a spinner takes the place of the first icon and it can't be pressed. Screen readers hear "Loading".
    'loading' => false,
    // Greyed out: it can't be pressed.
    'disabled' => false,
    // An icon before the text.
    'iconStart' => null,
    // An icon after the text.
    'iconEnd' => null,
    // An icon-only, square button: shows just this icon, and needs a label.
    'icon' => null,
    // With icon: the button's name for screen readers and its tooltip. Required there.
    'label' => null,
    // type="submit": once its form submits, the button shows as loading so the form can't be sent twice.
    // false turns it off.
    'submitGuard' => true,
])

@php
    // Disabled looks are skipped while loading (aria-busy), so a loading button keeps its colour.
    $variants = [
        'primary' => 'bg-primary-fill 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-fill 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'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! array_key_exists($variant, $variants)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.button>. Use one of: ".implode(', ', array_keys($variants)).'.');
    }
    if (! array_key_exists($size, $textSizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.button>. Use one of: ".implode(', ', array_keys($textSizes)).'.');
    }
    if (! in_array($type, ['button', 'submit', 'reset'], true)) {
        throw new \InvalidArgumentException("Unknown type [{$type}] for <x-widget.button>. Use one of: button, submit, reset.");
    }

    // 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 focus-visible:ring-offset-surface',
        // 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);
});