Toggle Button
A button that stays pressed: bold, italic, mute, pin. One control, two
states, aria-pressed doing the talking.
WireKit has three two-state controls and they are not interchangeable:
| Use | Component |
|---|---|
| A form setting the user saves ("Email notifications: on") | Toggle — a role="switch" with a label |
| Pick one of several options | Segmented Control |
| A single control that stays pressed | Toggle Button |
Basic Usage
Click either button: it flips between pressed and unpressed. The pressed state is carried by the surface and an edge: filled when pressed, with a line in the muted text color just inside its border, and outline when not. The edge stands at 3:1 or more against the fill in every preset, light and dark, so the state never depends on a tint alone.
These demos use self-toggle, which lets the button manage its own state so it
works with no wiring. In a real app you usually want the controlled form
below instead, where the pressed state lives in your component.
In a toolbar
Toggle buttons are what a formatting bar is made of.
Icons, Tooltips and State Labels
A toggle with only an icon is named by its tooltip, which also shows on hover and focus. The tooltip does not describe the button as well, so a screen reader reads the name once:
on-icon and off-icon give each state its own glyph. Both icons stay in the button, stacked in one place, so it keeps its width as they trade places:
active-intent colors the icon while the toggle is pressed: accent (the default), success, warning, danger or info. primary is accepted as accent, the color it names in every other component. The pressed surface changes as well, so the color is never the only sign of the state:
on-label and off-label give each state its own words. The label then says the state, so the button renders without aria-pressed, and its name is whichever label is showing:
Livewire
The component is controlled: the pressed state lives in your component, not in the button. That is deliberate — the truth about "is this text bold" belongs to the document.
{{-- 1. Bind the state, handle the click — no local JS state to drift --}}
<x-wirekit::toggle-button :pressed="$bold" wire:click="$toggle('bold')" aria-label="Bold">
B
</x-wirekit::toggle-button>
// 2. One property is the whole state
public bool $bold = false;
wire:model binds a boolean property to the pressed state in both directions, with no click handler of your own:
{{-- 1. The click flips $starred, and a change to $starred on the server flips the toggle --}}
<x-wirekit::toggle-button icon="star" tooltip="Starred" wire:model.live="starred" />
wire:model and optimistic both write the pressed state, so use one of them. With both set, optimistic is kept, wire:model is dropped, and the log says so.
In an Alpine component of your own, x-model on the tag binds the pressed state the same way. See An x-model on a WireKit component.
Optimistic UI
Pass the name of the Livewire method the button should call and the pressed state flips straight away, while it goes:
<x-wirekit::toggle-button :pressed="$bold" optimistic="toggleBold">Bold</x-wirekit::toggle-button>
Load wirekit-optimistic. alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></script>
Try it
The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.
Accepted
The press is kept and nothing is said.
Refused
The press is taken back, and said out loud.
Slow to answer
The dashed outline is the provisional state.
The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.
A refusal puts the state back, because a pressed state is a discrete choice: returning to it costs you nothing, it is simply the other option.
One thing to know before you opt in. The announcement needs an element of its own, and this component is the button — so with optimistic set, a wrapper is added around it. It uses display: contents, so the layout is unchanged and the button keeps its position in any flex or grid row. What does change is the element tree: the button is no longer a direct child of its container, so a selector like .toolbar > button (or :first-child, +, ~) does not match it. Without the prop, nothing is added.
optimistic and selfToggle do not combine — the optimistic layer performs the flip, so a local one would fight it. Setting both lets the layer win.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
pressed |
bool | false |
The two-state truth; drives aria-pressed and the surface. Also the initial state in self-toggle mode |
selfToggle |
bool | false |
Let the button flip its own state on click, with no wiring (attribute: self-toggle). Handy for a standalone toolbar; leave it off for the controlled Livewire form above |
size |
string | 'md' |
Forwarded to the underlying button |
icon |
string|null |
null |
An icon shown in both states |
onIcon |
string|null |
null |
The icon while pressed. Set together with offIcon |
offIcon |
string|null |
null |
The icon while not pressed. Set together with onIcon |
tooltip |
string|null |
null |
A hint on hover and focus. On a toggle with no visible label it is also the accessible name |
activeIntent |
string |
'accent' |
The pressed icon's color: accent, success, warning, danger or info; primary is taken as accent |
onLabel |
string|null |
null |
The label while pressed. Set together with offLabel; the toggle then renders without aria-pressed |
offLabel |
string|null |
null |
The label while not pressed. Set together with onLabel |
scope |
string|null | null |
Scoped personalization name |
optimistic |
string|null |
null |
Livewire method to call on click, showing the new pressed state before the server agrees. A refusal puts the old state back. Adds a wrapper element — see Optimistic UI. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
Everything else — wire:click, aria-label, disabled, classes — passes
through to the underlying Button.
Accessibility
- The control is a real
<button type="button">witharia-pressed— the WAI-ARIA toggle-button pattern. Assistive technology announces "pressed" / "not pressed" without any extra wiring. - Never color-only: pressed switches the surface (filled vs outline), so the state is visible in monochrome and high contrast too (WCAG 1.4.1).
- An icon-only toggle needs a name: a
tooltip, which also names it, or anaria-label. A glyph is not a name, and a tooltip alone would not reach a touch screen, where nothing hovers. - With
on-labelandoff-labelthere is noaria-pressed. The WAI-ARIA button pattern says a toggle's label must not change with its state, and that a label which does change makesaria-pressedunnecessary: the words already say the state. The name is the label that is showing. - The icons are decorative (
aria-hidden). Under forced colors, where every background is replaced, a pressed toggle fills with the system selection color so the state stays visible, and its focus outline takes the system text color, so a pressed toggle that has focus still shows it. - It composes Button, so the focus ring, disabled model and sizes are the same ones used everywhere else.
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Focus the button |
| Enter / Space | Toggle the pressed state |
Pitfalls
- Do not use it for a saved setting. That is Toggle, which is a labeled form switch.
- Do not use it for one-of-many. That is Segmented Control.
- Do not leave an icon-only toggle unnamed. In debug it throws; in a request it logs a warning.
- Do not add an
aria-labelto a toggle withon-labelandoff-label. It would name the button with words that are not on it (WCAG 2.5.3 Label in Name), so it is dropped and a warning is logged.
Design Tokens
The pressed and unpressed surfaces come from Button's
filled and outline surfaces on the neutral intent — so a theme change
restyles it with everything else. The pressed edge is --color-wk-text-muted, drawn
--border-wk-width wide and never under 1px.