# Segmented control

Chooses one of a few options drawn as joined segments, such as a view or a period.

- **Import:** `import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"`
- **Radix Radio Group:** <https://www.radix-ui.com/primitives/docs/components/radio-group>
- **APG Radio Group:** <https://www.w3.org/WAI/ARIA/apg/patterns/radio/>
- **Page:** <https://ui.booleanpress.com/components/segmented-control> · @booleanpress/ui 0.2.0

## Usage

A segmented control is a radio group drawn as one bar: exactly one segment is chosen, and a raised plate slides to it. For buttons that each turn on and off, use a [toggle group](/components/toggle-group).

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export function TicketView() {
  return (
    <SegmentedControl defaultValue="list" aria-label="Ticket view">
      <SegmentedControlItem value="list">List</SegmentedControlItem>
      <SegmentedControlItem value="board">Board</SegmentedControlItem>
    </SegmentedControl>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`. The segments share the width equally, so the plate moves by a transform alone; put the segments straight inside `SegmentedControl` for it to slide (wrapped segments each draw their own plate). `size` is `sm`, `default` or `lg` (12, 14 or 16 px text) and follows the provider's `controlSize`; `fluid` fills the container. `disabled` on the control disables every segment, on a segment that one alone; `aria-invalid` draws the error edge. Name the control with `aria-label` or `aria-labelledby`, and every icon-only segment with `aria-label`. With `name`, the choice is submitted in a form.

## Examples

### Basic

Three views; the chosen one sits on a raised plate that slides.

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlBasic() {
  return (
    <SegmentedControl defaultValue="list" aria-label="Ticket view">
      <SegmentedControlItem value="list">List</SegmentedControlItem>
      <SegmentedControlItem value="grid">Grid</SegmentedControlItem>
      <SegmentedControlItem value="board">Board</SegmentedControlItem>
    </SegmentedControl>
  )
}
```

### With icons

An icon before each label, 14 px.

```tsx
import { KanbanIcon, LayoutGridIcon, ListIcon } from "lucide-react"
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlWithIcons() {
  return (
    <SegmentedControl defaultValue="grid" aria-label="Ticket view">
      <SegmentedControlItem value="list">
        <ListIcon /> List
      </SegmentedControlItem>
      <SegmentedControlItem value="grid">
        <LayoutGridIcon /> Grid
      </SegmentedControlItem>
      <SegmentedControlItem value="board">
        <KanbanIcon /> Board
      </SegmentedControlItem>
    </SegmentedControl>
  )
}
```

### Icon only

Icons alone, each segment named with `aria-label`.

```tsx
import { AlignCenterIcon, AlignJustifyIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react"
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlIconOnly() {
  return (
    <SegmentedControl defaultValue="left" aria-label="Text alignment">
      <SegmentedControlItem value="left" aria-label="Align left">
        <AlignLeftIcon />
      </SegmentedControlItem>
      <SegmentedControlItem value="center" aria-label="Align centre">
        <AlignCenterIcon />
      </SegmentedControlItem>
      <SegmentedControlItem value="right" aria-label="Align right">
        <AlignRightIcon />
      </SegmentedControlItem>
      <SegmentedControlItem value="justify" aria-label="Justify">
        <AlignJustifyIcon />
      </SegmentedControlItem>
    </SegmentedControl>
  )
}
```

### Sizes

`sm`, `default` and `lg`: 12, 14 and 16 px text, 32, 35 and 38 px tall.

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

const SIZES = [
  { size: "sm", label: "Small" },
  { size: "default", label: "Default" },
  { size: "lg", label: "Large" },
] as const

export default function SegmentedControlSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      {SIZES.map(({ size, label }) => (
        <SegmentedControl key={size} size={size} defaultValue="week" aria-label={label}>
          <SegmentedControlItem value="day">Day</SegmentedControlItem>
          <SegmentedControlItem value="week">Week</SegmentedControlItem>
          <SegmentedControlItem value="month">Month</SegmentedControlItem>
        </SegmentedControl>
      ))}
    </div>
  )
}
```

### Fluid

`fluid` fills the container; the segments share it equally.

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlFluid() {
  return (
    <SegmentedControl fluid defaultValue="sent" aria-label="Message folder" className="max-w-xl">
      <SegmentedControlItem value="sent">Sent</SegmentedControlItem>
      <SegmentedControlItem value="scheduled">Scheduled</SegmentedControlItem>
      <SegmentedControlItem value="failed">Failed</SegmentedControlItem>
    </SegmentedControl>
  )
}
```

### Disabled

`disabled` on the control greys the whole bar; on a segment, only that one is out of the choice.

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlDisabled() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      <SegmentedControl disabled defaultValue="off" aria-label="Tracking">
        <SegmentedControlItem value="off">Off</SegmentedControlItem>
        <SegmentedControlItem value="on">On</SegmentedControlItem>
      </SegmentedControl>
      <SegmentedControl defaultValue="monthly" aria-label="Billing period">
        <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
        <SegmentedControlItem value="yearly" disabled>
          Yearly
        </SegmentedControlItem>
      </SegmentedControl>
    </div>
  )
}
```

### Invalid

`aria-invalid` draws the `--invalid` edge round the bar, with the message in `aria-describedby`.

```tsx
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

export default function SegmentedControlInvalid() {
  return (
    <div className="flex flex-col items-center gap-2">
      <SegmentedControl aria-label="Billing period" aria-invalid aria-describedby="period-error">
        <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
        <SegmentedControlItem value="yearly">Yearly</SegmentedControlItem>
      </SegmentedControl>
      <p id="period-error" className="text-sm/normal text-destructive-strong">
        Choose how often to bill.
      </p>
    </div>
  )
}
```

## Accessibility

**Semantics.** A `role="radiogroup"` of `role="radio"` buttons with `aria-checked`. The sliding plate is decoration, hidden from screen readers. Inside a form, the chosen segment also renders a hidden native input.

**Labels.** Name the control with `aria-label` or `aria-labelledby`. Each segment is named by its text, or by its `aria-label` when it shows an icon alone.

**Focus.** The control is one tab stop, on the chosen segment (the first when none is). Keyboard focus is a 1px `--ring` outline 2px outside the segment.

**Known limits.**

- Equal segments take the width of the widest; keep labels short, or the bar grows with the longest one. Where the container is narrower than that, the segments stay equal: a label of several words wraps, and a single word too long for its segment is cut off at its end.
- The plate slides only between segments given straight as children.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Tab | Moves to the chosen segment, then out of the control. |
| → or ↓ | Chooses the next segment, from the last back to the first; → goes the other way in right-to-left pages. |
| ← or ↑ | Chooses the previous segment, from the first round to the last; ← goes the other way in right-to-left pages. |
| Space | Chooses the focused segment when it is not chosen yet. |

## API

### SegmentedControl

Renders Radix RadioGroup.Root and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `defaultValue` | `string` |  | The chosen segment's value at the start, when it controls itself. |
| `dir` | `"ltr" \| "rtl"` |  | The reading direction. The provider's `dir` by default. |
| `disabled` | `boolean` |  | Disables every segment. |
| `fluid` | `boolean` | `false` | Fills the width of its container; the segments share it equally. |
| `form` | `string` |  |  |
| `loop` | `boolean` |  | Whether the arrow keys wrap from the last segment to the first. True by default. |
| `name` | `string` |  | The form field name; the chosen value is submitted with the form. |
| `onValueChange` | `((value: string) => void)` |  | Called with the chosen segment's value. |
| `required` | `boolean` |  | The form cannot be submitted until a segment is chosen. |
| `size` | `"default" \| "sm" \| "lg"` |  | The text size: 12, 14 or 16 px, 32, 35 or 38 px tall. Defaults to the provider's `controlSize`. |
| `value` | `string` |  | The chosen segment's value, when you control it. |

### SegmentedControlItem

Renders Radix RadioGroup.Item and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `checked` | `boolean` |  |  |
| `required` | `boolean` |  |  |
| `value` | `string \| null` |  | The value the control holds while this segment is chosen. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="segmented-control"` (SegmentedControl), `data-slot="segmented-control-item"` (SegmentedControlItem), and `data-size`.

## Theming

The bar is `--muted` (`--background` in dark) with a 1px edge of its own colour; the chosen segment's plate is `--background` (`--muted` in dark) with a faint shadow, and its text `--accent-foreground`. Other segments are `--muted-foreground`, `--secondary-hover-foreground` under the pointer. The plate slides over `--bui-duration-base`, and not at all with reduced motion. Disabled takes `--field-disabled` and `--field-disabled-foreground`; invalid the `--invalid` edge.

| Token | Used for |
| --- | --- |
| `--accent-foreground` | text |
| `--background` | border, background |
| `--field-disabled` | border, background |
| `--field-disabled-foreground` | text |
| `--invalid` | border |
| `--muted` | border, background |
| `--muted-foreground` | text |
| `--ring` | outline |
| `--secondary-hover-foreground` | text |
