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>
A wait a screen reader hears
The spinner is hidden from assistive technology, so a wait that lasts past a second is announced instead: the button says "Loading", in the application's language, through a status region of the page, and the region empties when the request ends. A request that answers sooner says nothing, so a quick save does not talk over its own result. The region stays reachable while a dialog is open, so a wait started in one is heard as well.
Give the wait a word of your own with data-wk-busy-text:
<x-wirekit::button loading wire:click="save" data-wk-busy-text="Saving">Save changes</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:: 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:
The icon alone
icon-only shows the glyph and keeps the label — visually hidden, still the button's accessible name. Reach for it where a row of actions has to narrow: a table action, a toolbar, a card header.
The box becomes a square — the width is the same sizing token as the height, so it is as wide as it is tall at every size instead of as wide as its padding. In a flex row that is short of space the square keeps its width instead of being squeezed into a narrow bar; give it shrink if it should give way. Every size clears the 24×24 CSS-pixel target of WCAG 2.5.8; the smallest, xs, is 28px.
On a touch screen the square stays a square. Every button there has a 44px minimum height, the touch target this library holds itself to, and an icon-only button takes it as an invisible 44×44 hit area centered on the glyph instead: the button is drawn at its size and a finger can hit it across 44px. Welded into a button group, where the buttons touch, they keep the 44px height and no hit area reaches into a neighbor.
Keep the label in the default slot — do not delete it. An icon-only button with nothing to be named by is announced as "button" and nothing else, and the defect is invisible to whoever built it because the glyph tells them everything. WireKit refuses that button rather than inventing a name for it: in the console it throws, and in a request it logs and renders. Pass aria-label instead if you prefer, but pass one of the two.
Beside labeled buttons on a touch screen
A labeled button stands at 44px on a touch screen, and an icon-only button keeps its drawn size there, so a row that mixes the two has two heights on a phone. Give the icon-only buttons of such a row touch-floor: they take the touch target as their size, a 44px square, and drop the hit area around them, which they no longer need. The row below carries data-wk-touch, so it shows the touch sizes with a mouse as well.
Leave it off in a dense toolbar, such as the bold, italic and underline buttons of a text editor: squares grown to 44px run over a bar like that, so there they keep their drawn size and answer the finger through the area around them.
Showing the label only when there is room
A row action usually wants the label on a wide screen and the glyph alone on a phone. icon-only-below takes a breakpoint name and does exactly that, with one control:
{{-- Labeled at lg and above, the glyph alone below it. --}}
<x-wirekit::button size="sm" surface="outline" icon-only-below="lg">
<x-slot:iconLeft><x-wirekit::icon name="edit" size="sm" aria-hidden="true" /></x-slot:iconLeft>
{{ __('Edit roles') }}
</x-wirekit::button>
The label stays in the accessibility tree at both widths — it is hidden visually, not removed — so the button's name does not change when a reader rotates their phone. Below the breakpoint the box becomes the same square icon-only produces at that size.
The breakpoints are the ones Tailwind defines, sm to 2xl, and one below them: xs is 360px. sm hides the label on every phone, including the ones a short bar still fits on; xs keeps it there and hides it only on the smallest screens.
It measures the window, not your column
icon-only-below asks about the viewport, and that is a deliberate limit rather than an oversight. A button sits anywhere, so a container-relative variant would have to measure the nearest container, whichever that is — and a caller nesting it in a container of their own would silently retarget it. table.th hide can offer the container question because the kit names the container it measures.
Where the column's width is genuinely the right question, write the two container variants yourself and keep two controls:
{{-- 1. The wrapper names the container the query measures. --}}
<div class="@container/row-actions">
{{-- 2. Icon-only below 32rem of THAT container, labeled above it. --}}
<x-wirekit::button size="sm" surface="outline" class="@lg/row-actions:hidden" icon-only>
<x-slot:iconLeft><x-wirekit::icon name="edit" size="sm" aria-hidden="true" /></x-slot:iconLeft>
{{ __('Edit roles') }}
</x-wirekit::button>
<x-wirekit::button size="sm" surface="outline" class="@max-lg/row-actions:hidden">
<x-slot:iconLeft><x-wirekit::icon name="edit" size="sm" aria-hidden="true" /></x-slot:iconLeft>
{{ __('Edit roles') }}
</x-wirekit::button>
</div>
That is two controls, one of them always hidden — two elements, two wire:click bindings, two of everything your action needs. It is the honest cost of the container question, and it is small for a row action; weigh it before repeating it fifty times down a table.
icon-only and icon-only-below contradict each other — one hides the label at every width, the other only below one. Passing both is refused rather than silently resolved.
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.
A neutral button on the soft surface has a fill of its own, --color-wk-bg-soft: the text color mixed into
the surface the button stands on. It is light on the page and on a card, and darker on a card footer, a flat
card or a muted area, so it stands apart from each of them in both modes, and an icon button keeps its shape
on each:
A change to this attribute applies to every product that uses it.
3 products selected
A gray surface of your own
Every surface WireKit paints tells the button which one it stands on: a card and its footer, a band
with a surface, a dropdown, a modal or a popover, an entry of a menu under the pointer, a striped or
a selected table row. An element of yours painted with the same classes, such as
bg-[var(--color-wk-bg-muted)] or hover:bg-[var(--color-wk-bg-hover)], is followed the same way.
A gray area painted another way, with a style or a class of your own, names its surface with
data-wk-surface, set to the surface token its background uses: page, elevated, subtle, muted,
hover or active. A badge follows the same surface:
{{-- 1. A gray panel painted with a style names the surface token its background uses. --}}
<div data-wk-surface="muted" class="p-4" style="background: var(--color-wk-bg-muted)">
{{-- 2. The button inside takes the shade that stands apart from muted. --}}
<x-wirekit::button intent="neutral" surface="soft">Filter</x-wirekit::button>
</div>
An element of yours that turns gray only under the pointer, painted another way, takes
data-wk-surface-hover with the surface token of its hover background, from the same six. A badge in
it then darkens with the element instead of vanishing into it.
Paint that hover with --color-wk-bg-hover, as the class hover:bg-[var(--color-wk-bg-hover)]. On the
page it is the theme's own hover shade, and inside a card, a panel or another gray surface WireKit paints
it is that surface with 4% of the text color, so a row of yours under the pointer stands apart in every
theme and both modes, and a badge in it takes its fill from the hover. A bg-subtle hover does not: in
a dark mode it has the color of a card and a panel. A row that is chosen, current or highlighted takes
--color-wk-bg-active the same way, as bg-[var(--color-wk-bg-active)]. It stands apart from the
surface and from a hover beside it, where bg-muted has the color of a panel in some themes.
Button Group
Wrap buttons in <x-wirekit:: 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:
Stacking on a narrow screen
A group holds one shape at every width, so a row that fits on a desktop can run off a
phone, the last label past the edge of the screen. stack-below names the width under which
the group becomes a column.
It joins on the block axis there, and on the inline axis from that width up.
<x-wirekit::button.group stack-below="md" label="Document actions">
<x-wirekit::button intent="neutral" surface="outline">Save draft</x-wirekit::button>
<x-wirekit::button intent="neutral" surface="outline">Translate document</x-wirekit::button>
<x-wirekit::button intent="neutral" surface="outline">Mark as reviewed</x-wirekit::button>
</x-wirekit::button.group>
The breakpoint names are the ones hide-below takes on a table column: sm, md, lg,
xl and 2xl. A vertical group ignores the prop, because it already stacks at every
width.
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).
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 would 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 with touch-text-floor:
<x-wirekit::select aria-label="Language" size="sm" name="locale" :options="$locales" />
<x-wirekit::button size="sm" touch-text-floor>Sign in</x-wirekit::button>
The button then reads --text-wk-touch-floor, the value the fields read, so the two stay equal when a
theme changes it. The floor 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 |
touchTextFloor |
bool | false |
On a touch screen, lift the label to --text-wk-touch-floor, the size form fields lift to so iOS does not zoom on focus. For a button that shares a row with a field; a button on its own keeps its designed size |
touchFloor |
bool | false |
On a touch screen, stand at the 44px touch target instead of the drawn size: an icon-only button becomes a 44px square with no hit area around it. For an icon button in a row with labeled buttons, which stand at 44px there; in a dense toolbar leave it off — see Beside labeled buttons on a touch screen |
iconOnly |
bool | false |
Show the icon alone: the horizontal padding goes and the box becomes a square the height of its size. Leave the label in the default slot — it is rendered visually hidden and stays the accessible name. A button with neither a label nor an aria-label is refused rather than given an invented name. wrapLabel is ignored, because there is no visible label to wrap — see The icon alone |
iconOnlyBelow |
xs | sm | md | lg | xl | 2xl | null |
null |
Show the label at and above this viewport width and the glyph alone below it. The label stays in the accessibility tree at both ends, so the button's name does not change with the window. Contradicts iconOnly; passing both is refused — see The icon alone |
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), and announces a wait that lasts past a second |
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 only marks it busy. 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 control keeps the focus and its place in the tab order, and assistive technology is told it is busy. Either way a wait that lasts past a second is announced (A wait a screen reader hears). 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) |
stackBelow |
string|null | null |
'sm', 'md', 'lg', 'xl' or '2xl' — the width under which a horizontal group becomes a column, joined on the block axis there and on the inline axis from that width up. A group that is already orientation="vertical" ignores it. See Stacking on a narrow screen |
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., preventing double submissions. The spinner SVG includesattr="disabled" aria-hidden="true", so a wait that lasts past a second is announced through a status region of the page instead, in the application's language. -
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: the control keeps the focus and its place in the tab order, and assistive technology is told it is busy. A long wait is announced the same way in both. 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\ — 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>