Button
A flexible button component composed from intent (semantic color) and surface (visual style), with sizes, icons, and a loading state.
Live Sandbox
The "Live preview" toggle on the block below swaps the static rendered HTML for a hydrated Livewire instance — every prop in the schema becomes editable from the iframe and the result re-renders server-side. Toggle off to return to the static preview.
Basic Usage
Sizes
As Link
Renders as <a> instead of <button>.
Loading State
<x-wirekit::button loading wire:click="save">Save changes</x-wirekit::button>
The loading prop adds a spinner that is visible only during Livewire requests (via wire:loading). The button is also automatically disabled while loading to prevent double-clicks.
On a page that polls (wire:poll) or has several action buttons, add loading-target so the spinner only reacts to this button's own action instead of flashing on every commit:
<x-wirekit::button loading wire:click="redeliver('{{ $id }}')" loading-target="redeliver">Retry</x-wirekit::button>
The preview below simulates that behavior with Alpine — clicking the button flips into a 2-second "saving" state so you can see the spinner + disabled treatment without a Livewire backend:
Click the button.
Icons
With WireKit Icon Component
Use the iconLeft or iconRight slots together with the Icon component:
With Custom SVG
You can also pass raw SVGs for full control — use the iconLeft / iconRight slots and size the SVG with an inline style="width: 1rem; height: 1rem;" (1rem = Tailwind w-4 h-4) so the glyph matches the default icon sizing:
Mix the WireKit <x-wirekit::icon> component with raw SVGs in the same button row — the two sizing approaches (class="w-4 h-4" on the icon component vs style="width: 1rem; height: 1rem;" on the raw SVG) render identically, so they can be used side by side:
Intent × Surface
The button is composed from two orthogonal props: intent selects the semantic color (primary, neutral, success, warning, danger, info) and surface selects the visual style (filled, outline, soft, ghost, link). Any combination is valid — the same color reads as a strong action when filled, a neutral chrome when outlined, and a tertiary affordance when soft or ghost.
Button Group
Wrap buttons in <x-wirekit::button.group> to join them into a single
segmented control — inner corners are squared, the shared border seam collapses
to one line, and the active button rises above the seam. Add label for the
group's accessible name, and orientation="vertical" to stack instead.
The same wrapper joins an input with a trailing button — an attached search field, a newsletter signup, or a copy-link row:
Width & Layout
Buttons auto-size to their content by default. Use Tailwind classes for full-width or fixed-width buttons:
{{-- Full-width button --}}
<x-wirekit::button class="w-full">Submit</x-wirekit::button>
{{-- Fixed width --}}
<x-wirekit::button class="w-48">Save Changes</x-wirekit::button>
Height is controlled by the size prop (xs, sm, md-compact, md, lg, xl).
Touch targets
xs (~28px tall) sits below the ~44px comfortable touch-target size. Reach for it in
dense desktop UIs — toolbars, table-row actions, filter chips — not for a primary
action a finger has to hit on a phone, where md or larger keeps the target roomy.
A label somebody else typed
The default keeps a label on one line, and for a label you wrote that is right — Save,
Send test, Delete forever all belong on one row, and one that wrapped mid-word would be
the defect.
It is the wrong answer for a name a user typed. A template name, a channel label, a
repository login are as long as somebody made them, and a row of buttons carrying them runs
off the edge of its card rather than taking a second line. wrap-label hands that decision
to the caller:
The second button is the same label with the spinner running, and it is there on purpose:
the reason the default clamps to one line is that a wrapping label used to push the spinner
above the text. wrap-label holds the flex line together, so the label takes its second row
and the spinner stays beside it.
The third is a name with no space or hyphen in it, which is the shape a template name, a channel or a repository usually has. It breaks inside the word, and only where nothing else fits: a label that does have a space still breaks at the space.
Reaching for class="whitespace-normal" from outside does not reliably work, and it is worth
knowing why: two Tailwind utilities for the same property are decided by their order in the
generated stylesheet, not by their order in your attribute. That is what this prop exists to
settle.
Beside a Field on a Touch Screen
On a touch screen, form fields lift their text to 16px, because iOS zooms into a field with smaller text the moment it is focused. Buttons keep their size, which is right for a button on its own. A small button in one row with a small select or input then reads in a different type size, so give that button the same floor:
<x-wirekit::select size="sm" name="locale" :options="$locales" />
<x-wirekit::button size="sm" class="pointer-coarse:text-[length:var(--text-wk-touch-floor)]">Sign in</x-wirekit::button>
--text-wk-touch-floor is the value the fields read, so the two stay equal when a theme changes it.
The class applies only on a touch screen; with a mouse the button keeps its size.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
intent |
string | 'primary' |
Semantic intent: primary, neutral, success, warning, danger, info |
surface |
string | 'filled' |
Visual surface: filled, outline, soft, ghost, link. outlined is accepted as an alias of outline |
size |
string | 'md' |
xs, sm, md-compact, md, lg, xl. md-compact is the 2.25rem tier between sm and md, for dense toolbars — see Input for the worked example |
type |
string | 'button' |
HTML button type |
wrapLabel |
bool | false |
Let the label take a second line. The default keeps it on one, which is right for a label you wrote — a button that wraps mid-word is the defect there. Turn it on for a label a user typed: a template name, a channel label, an org login are as long as somebody made them, and a row of those runs off the edge of a card instead of wrapping. A name with no space in it breaks inside the word where nothing else fits. The loading spinner stays beside the text either way |
href |
string|null | null |
Renders as <a> when set |
disabled |
bool | false |
Disables the button |
loading |
bool | false |
Shows spinner during Livewire requests (auto-detects wire:click context) |
loadingTarget |
string|null |
null |
Scopes the spinner + disable to this button's own Livewire action (e.g. loading-target="redeliver"). Without it, an untargeted wire:loading flashes the spinner on every commit — including wire:poll refreshes and unrelated actions. Setting it turns the busy state on, so loading is not needed alongside it. |
disableOnLoading |
bool | true |
Whether the busy state disables the control or merely announces itself. disabled blurs the element the moment it applies, so the control the reader just activated loses focus for the whole in-flight window — WCAG 2.4.3, on every request. false emits aria-busy instead: the same announcement, still focusable, still in the tab order. The trade is yours — a control that stays enabled can be pressed twice. |
forceLoading |
bool | false |
Renders the spinner unconditionally and disables the button regardless of any wire:loading gate. Useful for static documentation demos, non-Livewire contexts, or stories that want to preview the loading state without a backend request in flight. |
scope |
string|null | null |
Scoped personalization name |
<x-wirekit::button.group>
| Prop | Type | Default | Description |
|---|---|---|---|
orientation |
string | 'horizontal' |
'horizontal' (join left-to-right) or 'vertical' (stack) |
label |
string|null | null |
Accessible name for the group (role="group") |
scope |
string|null | null |
Scoped personalization name |
Slots
| Slot | Description |
|---|---|
| default | Button label text |
iconLeft |
Content rendered before the label (e.g. SVG icon) |
iconRight |
Content rendered after the label (e.g. SVG icon) |
Accessibility
The button component uses native <button> semantics (or <a> when href is set), so browsers and assistive technology handle roles, focus, and activation automatically.
-
type="button"— Every<button>renders withtype="button"by default, preventing accidental form submission. Override withtype="submit"when needed. -
Disabled state — On a
<button>thedisabledprop sets the nativedisabledattribute. A link cannot be disabled natively —disabledis not a valid attribute on<a>— so on thehrefflavor the component withholds thehrefinstead and marks the elementrole="link" aria-disabled="true": it drops out of the tab order, cannot be followed, and is still announced as the disabled link it is. Both flavors getopacity-[var(--opacity-wk-disabled)]andpointer-events-none, so the control is visually muted and unreachable by pointer. -
Focus ring — Uses
focus-visible(notfocus) so the ring only appears for keyboard navigation, never for mouse clicks. -
Loading state — When
loadingis set, the button is automatically disabled during Livewire requests viawire:loading.attr="disabled", preventing double submissions. The spinner SVG includesaria-hidden="true", so the disable is what an assistive reader hears — which is also why it is the default. -
Loading state without losing focus —
disabledblurs the control the instant it applies. The element a reader just activated therefore stops being focused for the whole request and focus falls to the document body, which is a WCAG 2.4.3 failure on every request rather than an edge case. Set:disable-on-loading="false"and the busy state becomesaria-busyinstead: announced the same way, still focusable, still in the tab order. Weigh it against the double-submit the disable prevents; both are real, and only you know which matters on that screen. -
Icon-only buttons — When a button has no visible text label (icon only), add an
aria-labelattribute:<x-wirekit::button intent="neutral" surface="ghost" aria-label="Close"> <x-slot:iconLeft><x-wirekit::icon name="close" size="sm" /></x-slot:iconLeft> </x-wirekit::button> -
External links — When
hrefandtarget="_blank"are used together, the component automatically injectsrel="noopener noreferrer"(prevents tabnabbing) and a screen-reader hint "(opens in new tab)". -
Keyboard —
EnterandSpaceactivate the button (native browser behavior). No custom key handlers needed.
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus to the button |
Enter / Space |
Activate the button |
Pitfalls
- Don't omit
type="button"inside a<form>. The HTML default issubmit— clicking the button will submit the surrounding form even if you wired awire:clickhandler. The component already injectstype="button"for non-submit usage, but if you splat{{ $attributes }}over a wrapper, double-check the rendered HTML. - Don't hardcode
class="bg-..."to recolor. Useintent="primary|neutral|danger|..."(optionally combined withsurface=) so the component picks the right token-based foreground/background pair (auto-switches in dark mode). Hardcoded Tailwind colors bypass theming. - Don't add
aria-labelto a button with visible text. Screen readers read the visible label; thearia-labeloverrides it and creates a contradiction. Only setaria-labelon icon-only buttons.
Design Tokens
The button derives its color palette from intent × surface via Pushery\WireKit\VariantResolver — see Theming for the mapping. The chrome below is shared across every combination:
| Token | Used for |
|---|---|
--font-wk-sans |
Button font family |
--font-wk-body-weight |
Font weight |
--font-wk-letter-spacing |
Letter spacing |
--font-wk-line-height |
Line height |
--text-wk-sm / --text-wk-md / --text-wk-lg |
Font size per size prop |
--size-wk-sm / --size-wk-md-compact / --size-wk-md / --size-wk-lg |
Button height per size prop. xs and xl have no token of their own — they are calc() off --size-wk-sm and --size-wk-lg, so retuning those two moves all four rungs together. |
--padding-wk-x-sm / --padding-wk-x-md / --padding-wk-x-lg |
Horizontal padding per size |
--radius-wk-sm / --radius-wk-md / --radius-wk-lg |
Border radius per size |
--border-wk-width |
Border width |
--color-wk-ring / --color-wk-ring-offset |
Focus ring color + offset |
--ring-wk-width / --ring-wk-offset |
Focus ring geometry |
--opacity-wk-disabled |
Disabled visual weight |
--transition-wk-duration / --transition-wk-easing |
Hover / focus transition |
Usage & Conventions
Prop conventions — this component uses one or more of the shared semantic prop names (
intent/variant/tone/surface). See Prop naming conventions for the canonical vocabulary, alias matrix, and decision tree.
Further Reading
- MDN:
<button>element - WAI-ARIA Button Pattern
- MDN:
typeattribute — whytype="button"matters inside<form>