Skip to main content
Copy for LLM

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.

Sandbox

Basic Usage

Common shapes

Sizes

Sizes
A button that is really a 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 to simulate a Livewire request

Click the button.

Icons

With WireKit Icon Component

Use the iconLeft or iconRight slots together with the Icon component:

Buttons with icon aliases

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:

Single button with raw SVG

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:

Icon component and raw SVG 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 same actions with and without their labels

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.

Icon buttons as tall as the labeled button beside them

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

Primary intent across surfaces
Danger intent across surfaces
All intents with filled surface

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 soft neutral button on gray surfaces

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

Joined button group
Vertical button group

The same wrapper joins an input with a trailing button — an attached search field, a newsletter signup, or a copy-link row:

Input with attached button

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

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:

A label the user typed

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 with type="button" by default, preventing accidental form submission. Override with type="submit" when needed.

  • Disabled state — On a <button> the disabled prop sets the native disabled attribute. A link cannot be disabled natively — disabled is not a valid attribute on <a> — so on the href flavor the component withholds the href instead and marks the element role="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 get opacity-[var(--opacity-wk-disabled)] and pointer-events-none, so the control is visually muted and unreachable by pointer.

  • Focus ring — Uses focus-visible (not focus) so the ring only appears for keyboard navigation, never for mouse clicks.

  • Loading state — When loading is set, the button is automatically disabled during Livewire requests via wire:loading.attr="disabled", preventing double submissions. The spinner SVG includes 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 — disabled blurs 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 becomes aria-busy instead: 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-label attribute:

    <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 href and target="_blank" are used together, the component automatically injects rel="noopener noreferrer" (prevents tabnabbing) and a screen-reader hint "(opens in new tab)".

  • Keyboard — Enter and Space activate 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 is submit — clicking the button will submit the surrounding form even if you wired a wire:click handler. The component already injects type="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. Use intent="primary|neutral|danger|..." (optionally combined with surface=) 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-label to a button with visible text. Screen readers read the visible label; the aria-label overrides it and creates a contradiction. Only set aria-label on 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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store