---
title: Toggle Button
description: A single two-state button that stays pressed — bold, italic, mute
visibility: guest
draft: false
---

# 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](/components/toggle) — a `role="switch"` with a label |
| Pick **one of several** options | [Segmented Control](/components/segmented-control) |
| A single control that stays pressed | **Toggle Button** |

## Basic Usage

:::preview{title="Click to toggle"}
<x-wirekit::row gap="sm" align="center">
    <x-wirekit::toggle-button :pressed="true" self-toggle>Bold</x-wirekit::toggle-button>
    <x-wirekit::toggle-button :pressed="false" self-toggle>Italic</x-wirekit::toggle-button>
</x-wirekit::row>
:::

Click either button: it flips between pressed and unpressed. The pressed state is
carried by the **surface** — filled when pressed, outline when not — so it survives
a monochrome or high-contrast rendering. It 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.

:::preview{title="A formatting cluster"}
<x-wirekit::row gap="sm" align="center">
    <x-wirekit::toggle-button :pressed="true" self-toggle aria-label="Bold">B</x-wirekit::toggle-button>
    <x-wirekit::toggle-button :pressed="false" self-toggle aria-label="Italic">I</x-wirekit::toggle-button>
    <x-wirekit::toggle-button :pressed="false" self-toggle aria-label="Underline">U</x-wirekit::toggle-button>
</x-wirekit::row>
:::

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

```blade
{{-- 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>
```

```php
// 2. One property is the whole state
public bool $bold = false;
```

## Optimistic UI

Pass the name of the Livewire method the button should call and the pressed state flips straight away, while it goes:

```blade
<x-wirekit::toggle-button :pressed="$bold" optimistic="toggleBold">Bold</x-wirekit::toggle-button>
```

Load `wirekit-optimistic.js` alongside whichever bundle you already use — below it, in your layout:

```blade
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></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.

:::preview{title="Optimistic toggle button — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::row gap="lg" style="flex-wrap: wrap;">
    <x-wirekit::stack gap="xs" align="start">
        <x-wirekit::text size="sm" weight="medium">Accepted</x-wirekit::text>
        <x-wirekit::toggle-button :pressed="false" optimistic="demoAccept">Accepted</x-wirekit::toggle-button>
        <x-wirekit::text size="xs" variant="muted">The press is kept and nothing is said.</x-wirekit::text>
    </x-wirekit::stack>
    <x-wirekit::stack gap="xs" align="start">
        <x-wirekit::text size="sm" weight="medium">Refused</x-wirekit::text>
        <x-wirekit::toggle-button :pressed="false" optimistic="demoReject">Refused</x-wirekit::toggle-button>
        <x-wirekit::text size="xs" variant="muted">The press is taken back, and said out loud.</x-wirekit::text>
    </x-wirekit::stack>
    <x-wirekit::stack gap="xs" align="start">
        <x-wirekit::text size="sm" weight="medium">Slow to answer</x-wirekit::text>
        <x-wirekit::toggle-button :pressed="false" optimistic="demoSlow">Slow</x-wirekit::toggle-button>
        <x-wirekit::text size="xs" variant="muted">The dashed outline is the provisional state.</x-wirekit::text>
    </x-wirekit::stack>
</x-wirekit::row>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::toggle-button :pressed="$bold" optimistic="toggleBold">Bold</x-wirekit::toggle-button>
:::

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`, `+`, `~`) will not match it any more. Without the prop, nothing is added and the markup is exactly as before.

`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 |
| `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](#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](/components/button).

## Accessibility

- The control is a real `<button type="button">` with `aria-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 its own `aria-label` — a glyph is not a name.
- It composes [Button](/components/button), so the focus ring, disabled model
  and sizes are the same ones used everywhere else.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| <kbd>Tab</kbd> | Focus the button |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Toggle the pressed state |

## Pitfalls

- **Do not use it for a saved setting.** That is [Toggle](/components/toggle),
  which is a labeled form switch.
- **Do not use it for one-of-many.** That is
  [Segmented Control](/components/segmented-control).
- **Do not leave an icon-only toggle unnamed.**

## Design Tokens

The pressed and unpressed surfaces come from [Button](/components/button)'s
`filled` and `outline` surfaces on the `neutral` intent — so a theme change
restyles it with everything else.

## Further Reading

- [WAI-ARIA APG: Button (toggle)](https://www.w3.org/WAI/ARIA/apg/patterns/button/)
- [WAI-ARIA: `aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed)
