---
title: "SegmentedControl"
description: "A row of mutually exclusive options with exactly one chosen at a time, for switching a single view between a small set of windows."
url: "https://opsinjs.pensievelabs.org/components/segmented-control"
source: "https://opsinjs.pensievelabs.org/components/segmented-control.md"
section: "Components"
status: "shipped"
kind: "component"
category: "navigation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["segmented buttons", "toggle group"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="segmented-control"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured for the selected segment against the track or for its hairline, so greyscale legibility is argued rather than measured.&#x22;,
  &#x22;Nobody has listened to this control in a screen reader, so whether \&#x22;radiogroup\&#x22; is the register a reader expects for a button-like row is unchecked.&#x22;,
  &#x22;Forced colours and `prefers-contrast: more` are unhandled, so whether the selected segment still reads as selected in those modes is untested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="segmented-control" />

## Installation [#installation]

<ComponentInstall name="segmented-control" unbuilt="false" importPath="@/components/ui/segmented-control" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { SegmentedControl } from "@/components/ui/segmented-control"
```

```tsx
<SegmentedControl
  label="Chart window"
  value={chartWindow}
  onValueChange={setChartWindow}
  options={[
    { value: "day", label: "Day" },
    { value: "week", label: "Week" },
    { value: "month", label: "Month" },
  ]}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Switching one view between a small set of mutually exclusive windows, such as a chart's day, week or month range.&#x22;,
  &#x22;Choosing one setting from two to five short, comparable options that fit on one row and read as siblings.&#x22;,
  &#x22;A choice the reader changes often and wants the whole set in front of them.&#x22;,
]"
  avoid="[
  { case: &#x22;The options open different views with their own content, so choosing one swaps what the reader is looking at.&#x22;, instead: &#x22;tabs&#x22; },
  { case: &#x22;There are more than about five options, or the labels are long, so the row will not fit one line.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;The control performs an action rather than setting a value, such as saving a reading.&#x22;, instead: &#x22;button&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="segmented-control"
  parts="[
  {
    name: &#x22;SegmentedControl&#x22;,
    describes: &#x22;The root. A radiogroup on Base UI's Composite primitive, so the whole control is one tab stop.&#x22;,
    prop: &#x22;options&#x22;,
  },
  {
    name: &#x22;SegmentedControl.Segment&#x22;,
    describes: &#x22;One option, a Base UI Radio with role=\&#x22;radio\&#x22; and aria-checked. A disabled segment drops to muted ink.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;SegmentedControl.Label&#x22;,
    describes: &#x22;The option's words, the visible label and part of what a screen reader reads for the option.&#x22;,
  },
]"
/>

<CompositionTree
  name="segmented-control"
  tree="[
  {
    part: &#x22;SegmentedControl&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;role=\&#x22;radiogroup\&#x22;, one roving tab stop&#x22;,
    children: [
      {
        part: &#x22;SegmentedControl.Segment&#x22;,
        cardinality: &#x22;2..n&#x22;,
        note: &#x22;role=\&#x22;radio\&#x22;, aria-checked; one per option&#x22;,
        children: [
          { part: &#x22;SegmentedControl.Label&#x22;, cardinality: &#x22;1&#x22; },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Switching a chart window [#switching-a-chart-window]

The three options set the window of one chart rather than swapping between three
charts, which is why it is a radiogroup. The chart is a placeholder.

<ComponentPreview name="segmented-control-switching-a-chart-window" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Keep the labels short, parallel and in sentence case, one or two words each, so
the row reads as siblings. Name the value the option sets, not an
instruction, and give it no terminal punctuation. Order them the way the reader
thinks, shortest to longest, and keep one wording across screens.

<DoDont>
  <DoDont.Do>
    **"Day" · "Week" · "Month"** Three parallel windows, one word each, ordered
    shortest to longest.
  </DoDont.Do>

  <DoDont.Dont>
    **"Today" · "This week's readings" · "Show me the month"** Three lengths and
    three grammars, so the row stops reading as one set.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA**, in a source pass over the full rubric and a
rendered pass in the browser. The audit is author-run, not an independent
review, and clinical review is still pending.

A radio group, so the row is one tab stop with a roving focus, with
`radiogroup` plus `radio` and `aria-checked` per segment for name, role and
value. Selection is carried three ways and never by colour alone: `aria-checked`,
the card surface lifting the segment off the track, and a hairline.

**What the audit changed here.** The 44px hit-area floor sat on the height axis
only, so a single-character label or the `sm` size could shrink a segment below
the house minimum. The floor now sits on both axes, matching `scale-input` and
`tab-bar`.

**What a reader should still know.** `label` is applied only as the group's
`aria-label`, so there is no persistent visible group heading. Every option
carries visible text, so the choices are seen, but a visible group label is a
recommended enhancement, deferred as one repair across the wrappers that share
this pattern. No contrast pair has been measured, so greyscale legibility of the
selected segment is argued, not confirmed.

<KeyboardTable
  name="segmented-control"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the control, onto the selected segment&#x22;,
    notes: &#x22;The group is one tab stop. A disabled segment never takes it.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the control&#x22;,
    notes: &#x22;One stop in reverse too, so focus leaves the whole row.&#x22;,
  },
  {
    keys: &#x22;Arrow Right, Arrow Down&#x22;,
    action: &#x22;Moves to the next segment and selects it&#x22;,
    notes: &#x22;Selection follows focus. It wraps, skips disabled segments and mirrors under right-to-left.&#x22;,
  },
  {
    keys: &#x22;Arrow Left, Arrow Up&#x22;,
    action: &#x22;Moves to the previous segment and selects it&#x22;,
    notes: &#x22;Wraps from the first segment to the last and skips disabled segments.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Selects the focused segment&#x22;,
    notes: &#x22;A segment is a role=\&#x22;radio\&#x22;, so Space is its activator.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Does nothing&#x22;,
    notes: &#x22;The primitive cancels Enter, so a form is never submitted by accident.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Not bound&#x22;,
    notes: &#x22;The RadioGroup primitive disables them, so the browser's default applies.&#x22;,
  },
]"
/>

<ContrastReport component="segmented-control" />

## API reference [#api-reference]

<PropsTable name="SegmentedControlProps" />

`value` and `onValueChange` make this controlled, with no internal selection
state. A `value` matching no option renders the row with nothing chosen and
raises a development warning. `label` is required: a radiogroup with no
accessible name is a defect the type system cannot otherwise prevent.

## Related [#related]

* [Tabs](./tabs.mdx) swap between distinct panels of content. This control sets a
  parameter of one view.
* [Field](./field.mdx) with a select is the control for a choice too large or too
  wordy for one row.
* [Button](./button.mdx) performs an action rather than setting a value.
