---
title: Toggle
description: Toggle switch with label and hint
visibility: guest
draft: false
---

# Toggle

A switch control for binary on/off settings. Toggles are visually distinct from checkboxes and communicate an immediate state change (like flipping a lightswitch) rather than a form selection to be submitted later.

## Basic Usage

:::preview{title="Simple toggle"}
<x-wirekit::toggle name="notifications" label="Enable notifications" />
:::

## Sizes

:::preview{title="Small, medium, large"}
<x-wirekit::toggle name="t1" label="Small" size="sm" />
<x-wirekit::toggle name="t2" label="Medium (default)" size="md" />
<x-wirekit::toggle name="t3" label="Large" size="lg" />
:::

## With Hint

:::preview{title="Toggle with helper text"}
<x-wirekit::toggle name="marketing" label="Marketing emails" hint="Occasional product updates, no more than once a month." />
:::

## States

:::preview{title="Checked and disabled"}
<x-wirekit::toggle name="s1" label="On by default" checked />
<x-wirekit::toggle name="s2" label="Off and disabled" disabled />
<x-wirekit::toggle name="s3" label="On and disabled" checked disabled />
:::

## Livewire Integration

The toggle is a native `<input type="checkbox">` — `wire:model` works out of the box:

```blade
<x-wirekit::toggle
    name="notify"
    label="Notify me"
    wire:model.live="notifyEnabled"
/>
```

## Optimistic UI

Pass the name of the Livewire method the toggle should call and it shows the new state immediately, then confirms or undoes it when the server answers:

```blade
<x-wirekit::toggle
    name="notify"
    label="Notify me"
    :checked="$notifyEnabled"
    optimistic="toggleNotify"
/>
```

### Try it

The demo below runs the real path: the flip shows immediately, the outline says the value is
provisional, and the server's answer either confirms it silently or takes it back.

:::preview{title="Optimistic toggle — accept, reject, and a slow answer"}
<livewire:demos.optimistic-host>
    <x-wirekit::stack gap="lg" style="max-width: 26rem;">
        <x-wirekit::toggle name="opt-accept" label="Accepted — confirmation is silent" optimistic="demoAccept" />
        <x-wirekit::toggle name="opt-reject" label="Refused — the flip is taken back, and said out loud" optimistic="demoReject" />
        <x-wirekit::toggle name="opt-slow" label="Slow answer — the dashed outline is the provisional state" optimistic="demoSlow" />
    </x-wirekit::stack>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::toggle
    name="notify"
    label="Notify me"
    :checked="$notifyEnabled"
    optimistic="toggleNotify"
/>
:::

The `<livewire:demos.…>` wrapper above exists only on this site — it supplies the three demo
methods so the page can show a real round trip. The block under it is what you write.

It is a method name rather than a boolean because the component cannot know the action otherwise — server actions reach a WireKit component through the attribute bag, so the component never sees your `wire:click`.

**`optimistic` replaces `wire:model.live` here rather than joining it.** The method you name is what changes the value on the server, and the toggle shows the change while that call is in flight. Pass the current value with `:checked` so the toggle knows where it started; a plain `wire:model` (without `.live`) alongside it is still fine, since it only submits with the surrounding form.

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

It is a separate file on purpose: loading it is how you opt into the announcement behavior described below, so applications that do not use it pay nothing for it. Without it the toggle keeps working exactly as it always has — the prop simply has nothing to mount.

::: info What a screen reader hears
The flip announces once, hedged — "Saving" — so the state is audible as provisional. **Confirmation is silent**: what was announced is what happened. Only a deviation speaks a second time, which is what makes the undo recognizable as an undo rather than as another confirmation.

Where the toggle already shows a validation error, the undo stays **silent** and leaves the field's own message to speak. "Email is required" tells you what to do; "could not save" does not.

An aborted request announces nothing at all — nothing was refused, so there is nothing to take back.

Focus stays exactly where you put it. An undo arrives on the server's schedule, and moving focus then would take you out of your place for a reason you could not predict.
:::

A second flip while one is in flight is **refused** rather than queued. Queued, the final state would depend on the order the responses came back in — network timing — which is both wrong and impossible to test.

**Scope.** This covers mutations: changing something and showing it right away. Sorting, filtering, pagination and form submission are query round trips — nobody can show a result nobody knows yet — and are deliberately not covered.

## Design Decisions

- **Why `role="switch"` instead of `role="checkbox"`?** A switch represents an immediate state change (the setting applies right away). A checkbox represents a selection in a form (applied on submit). This distinction matters for screen reader users.
- **Why a native `<input>` instead of Alpine?** Native inputs are zero-JS, fully compatible with Livewire's `wire:model`, and inherit form submission behavior for free.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | string\|null | `null` | Text label displayed next to the toggle |
| `hint` | string\|null | `null` | Helper text rendered below |
| `error` | string\|null | `null` | Error message (also triggers `aria-invalid`) |
| `size` | string | `'md'` | `sm`, `md`, `lg` |
| `scope` | string\|null | `null` | Scoped personalization name |
| `optimistic` | string\|null | `null` | Livewire method to call, showing the new state before the server confirms it. 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. |
| `announceError` | bool\|null | `null` | Render the error message in a live region. Falls back to the parent form, then to config. |

All other attributes (`name`, `id`, `checked`, `disabled`, `wire:model`, etc.) pass through to the underlying `<input>`.

## Accessibility

- The underlying `<input>` has `role="switch"`, which tells assistive technology this is an on/off control (not a standard checkbox).
- The visual track and knob are purely decorative (`aria-hidden="true"`). Screen readers announce the native input's checked state.
- Focus ring appears on the **track** via `peer-focus-visible:*` utilities — keyboard users always see which toggle is focused.
- `aria-invalid`, `aria-describedby` are set automatically when `error` or `hint` are present.

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus to the button |
| `Enter` / `Space` | Activate the button |

## Pitfalls

- **Don't use a toggle for an action that requires a confirmation step.** Toggles should commit instantly; pair them with reversible state. For destructive actions use a button + `<x-wirekit::alert-dialog>`.
- **Don't bind `wire:model.live` if the action is expensive.** Each flick of the toggle round-trips. Debounce, or commit on a save button.

## Design Tokens

| Token | Purpose |
| --- | --- |
| `--color-wk-accent` | Track background when checked |
| `--color-wk-bg-muted` | Track background when off |
| `--color-wk-bg-elevated` | Knob background |
| `--color-wk-border-strong` | Track border when off |
| `--color-wk-ring` | Focus ring color |
| `--shadow-wk-sm` | Knob shadow |
| `--transition-wk-duration` | Knob slide + color animation |
| `--opacity-wk-disabled` | Dimmed state when disabled |

## Customization

Override defaults in `config/wirekit.php`:

```php
'components' => [
    'toggle' => ['size' => 'lg'],
],
```
