---
title: Segmented Control
description: Segmented toggle control
visibility: guest
draft: false
---

# Segmented Control

The `<x-wirekit::segmented-control>` component is a pill-style radio group for switching between a small set of mutually exclusive options. It uses `role="radiogroup"` with `role="radio"` on each segment, providing full keyboard navigation via arrow keys.

## Basic Usage

:::preview{title="View Switcher"}
<x-wirekit::segmented-control
    label="View"
    name="view"
    :options="['grid' => 'Grid', 'list' => 'List', 'table' => 'Table']"
    value="grid"
/>
:::

## Without Preselected Value

:::preview{title="No Default Selection"}
<x-wirekit::segmented-control
    label="Period"
    name="period"
    :options="['day' => 'Day', 'week' => 'Week', 'month' => 'Month', 'year' => 'Year']"
/>
:::

## Size Variants

:::preview{title="Three Sizes"}
<x-wirekit::stack gap="md">
    <x-wirekit::segmented-control label="Small" name="s-sm" size="sm" :options="['a' => 'One', 'b' => 'Two', 'c' => 'Three']" value="a" />
    <x-wirekit::segmented-control label="Medium" name="s-md" size="md" :options="['a' => 'One', 'b' => 'Two', 'c' => 'Three']" value="a" />
    <x-wirekit::segmented-control label="Large" name="s-lg" size="lg" :options="['a' => 'One', 'b' => 'Two', 'c' => 'Three']" value="a" />
</x-wirekit::stack>
:::

## Livewire Integration

```blade
<x-wirekit::segmented-control
    label="Sort"
    name="sort"
    :options="['newest' => 'Newest', 'oldest' => 'Oldest', 'popular' => 'Popular']"
    wire:model.live="sortOrder"
/>
```

The selected value is submitted via a hidden `<input type="hidden">` — `wire:model` binds to it automatically.

## Optimistic UI

Pass the name of the Livewire method the control should call and the segment moves immediately, then confirms or undoes itself when the server answers:

```blade
<x-wirekit::segmented-control
    name="tier"
    label="Tier"
    :value="$tier"
    :options="['basic' => 'Basic', 'plus' => 'Plus', 'max' => 'Max']"
    optimistic="chooseTier"
/>
```

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 segmented control — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::stack gap="lg" style="max-width: 26rem;">
    <x-wirekit::segmented-control name="opt-accept" label="Accepted — the confirmation is silent" value="plus" :options="['basic' => 'Basic', 'plus' => 'Plus', 'max' => 'Max']" optimistic="demoAccept" />
    <x-wirekit::segmented-control name="opt-reject" label="Refused — the old choice comes back, and the refusal is spoken" value="plus" :options="['basic' => 'Basic', 'plus' => 'Plus', 'max' => 'Max']" optimistic="demoReject" />
    <x-wirekit::segmented-control name="opt-slow" label="Slow answer — the dashed outline is the provisional state" value="plus" :options="['basic' => 'Basic', 'plus' => 'Plus', 'max' => 'Max']" 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::segmented-control
    name="tier"
    label="Tier"
    :value="$tier"
    :options="['basic' => 'Basic', 'plus' => 'Plus', 'max' => 'Max']"
    optimistic="chooseTier"
/>
:::

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.

The prop takes a method name rather than `true` because the component cannot know the action otherwise: server actions reach a WireKit component through the attribute bag, so it 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; pass the current one with `:value` so the control knows where it started.

Arrow keys go through the same path as a click: selection follows focus in a radio group, so moving with the keyboard is a pick like any other and behaves identically.

::: info What a screen reader hears
Picking a segment announces once, hedged — "Saving" — so the new value is audible as provisional. **Confirmation is silent**: what was announced is what happened. Only a deviation speaks a second time, which is what makes an undo recognizable as an undo.

Where the control sits in a form that already shows a validation message, the undo stays **silent** and leaves that message to speak: it tells you what to do, "could not save" does not.

An aborted request announces nothing at all — nothing was refused.

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string\|null` | `null` | Accessible group label |
| `error` | `string\|null` | `null` | Validation message. Rendered below the control, announced politely, and wired with `aria-invalid` + `aria-describedby` |
| `hint` | `string\|null` | `null` | Helper text below the control. An `error` replaces it — one message region, so the two cannot stack |
| `name` | `string\|null` | `null` | Form field name |
| `id` | `string\|null` | auto-generated | Base element id |
| `options` | `array` | `[]` | Associative array of `value => label` pairs |
| `value` | `string\|null` | `null` | Initially selected value |
| `optimistic` | `string\|null` | `null` | Livewire method to call, moving the segment before the server confirms it. Ignored when `disabled`. 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. |
| `size` | `string` | `'md'` | `'sm'`, `'md'`, `'lg'` |
| `disabled` | `bool` | `false` | Disabled state |
| `scope` | `string\|null` | `null` | Scoped personalization key |

## Accessibility

- Outer container: `role="radiogroup"` with `aria-label` from the `label` prop
- Each segment: `role="radio"` with `aria-checked="true"` / `"false"`
- Selection change announced automatically by screen readers via the radio role semantics
- The sliding pill indicator is `aria-hidden="true"` — purely decorative
- Disabled state sets `aria-disabled="true"` on the radiogroup and each segment
- Focus ring is visible on the focused segment via `focus-visible` styling

## Keyboard Interaction

| Key | Action |
| --- | --- |
| Tab | Move focus into the control |
| ArrowRight / ArrowDown | Move focus and selection to the next segment, wrapping to the first |
| ArrowLeft / ArrowUp | Move focus and selection to the previous segment, wrapping to the last |
| Home | Select the first segment |
| End | Select the last segment |

Selection follows focus, and both arrow axes wrap at the ends — the same behavior a native radio group has.

The component uses roving `tabindex` — only the active segment has `tabindex="0"`.

## Pitfalls

- **Don't use segmented-control for >5 options.** WAI-ARIA Toolbar Pattern degrades — overflow on narrow viewports clips. Reach for `<x-wirekit::tabs>` or `<x-wirekit::select>` for larger sets.

## Design Tokens

| Token | Purpose |
| --- | --- |
| `--color-wk-bg-muted` | Track background |
| `--color-wk-bg-elevated` | Active segment pill background |
| `--color-wk-text` | Active segment text color |
| `--color-wk-text-muted` | Inactive segment text color |
| `--color-wk-ring` | Focus ring color |
| `--color-wk-border` | Track border color |
| `--shadow-wk-sm` | Active segment pill shadow |
| `--size-wk-sm` / `md` / `lg` | Segment height per size variant |
| `--radius-wk-md` | Track border radius |
| `--radius-wk-sm` | Segment pill border radius |
| `--padding-wk-x-md` | Horizontal padding per segment |
| `--text-wk-sm` / `md` | Font size per size variant |
| `--transition-wk-duration` | Pill slide animation duration |
| `--opacity-wk-disabled` | Dimmed state when disabled |

## Customization

Override defaults in `config/wirekit.php`:

```php
'components' => [
    'segmented-control' => ['size' => 'md'],
],
```

## Further Reading

- [WAI-ARIA: Radiogroup Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/)
- [MDN: `role="radiogroup"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/radiogroup_role)
- [MDN: `role="radio"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/radio_role)
- [MDN: `aria-checked`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-checked)
- [MDN: Roving tabindex](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex)
