Navigation

SegmentedControl

An iOS-style segmented control: a row of equal segments with a raised thumb that slides to the checked one, for switching how one view shows its content in place, such as list or grid. Exactly one segment is checked. Screen readers hear a radio group, and the arrow keys check the next segment as they do for radio buttons. Segments are 44px tall on touch screens, and name submits the value with a form.

View

Showing this week's meals as a list.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

See it live, no sign-in needed: API playground

In its source: app/components/recipe/RecipeImportPanel.tsx · app/routes/developers.playground.tsx · app/routes/shopping-list.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/segmented-control#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/segmented-control

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/segmented-control#v1.2.13 ourostack/ouro-ui/segmented-control-tests#v1.2.13
import { SegmentedControl } from "@/components/ouro/segmented-control/segmented-control";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<SegmentedControl
  label="View"
  hideLabel
  items={[
    { value: "list", label: "List" },
    { value: "grid", label: "Grid" },
  ]}
  value={view}
  onValueChange={setView}
/>

Use instead when

  • Use Tabs instead when each choice shows different content with its own panel.
  • Use links in a <nav> instead when each choice is its own page.
  • Use a RadioGroup instead for a form value that is submitted.

Props

Type: SegmentedControlProps.

className
Type: string
Layout only: margins, grid placement (mt-4). Appearance classes warn in development.
defaultValue
Type: string
Default: the first enabled item
The segment pressed first, when the component keeps its own state.
disabled
Type: boolean
Disables every segment.
fullWidth
Type: boolean
Stretches the control to the width of its container, with the segments sharing it equally.
hideLabel
Type: boolean
Hides the label visually; it still names the control for screen readers. Set it on a view switcher whose segments say what it is.
items (required)
Type: readonly SegmentedControlItem[]
The segments, in order. Keep to two to five.
label (required)
Type: string
Names the choice ("View"). Shown above the control unless hideLabel is set; screen readers always hear it.
name
Type: string
Submits the checked segment's value under this name with the form it sits in, unless the control is disabled.
onValueChange
Type: ((value: string) => void)
Called with the new segment's value when the person presses a segment. Pressing the pressed one does nothing.
ref
Type: Ref<HTMLDivElement>
Reaches the group element (role="radiogroup").
value
Type: string
The pressed segment's value, when you control it. Pair it with onValueChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • onChange: Not a prop. Ouro UI uses onValueChange, which gets the value: <SegmentedControl onValueChange={(value) => …}>.
  • options: Not a prop. Ouro UI uses items: <SegmentedControl items={[{ value, label }]}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: SegmentedControl merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

SegmentedControlItem

One segment.

interface SegmentedControlItem {
  /** Identifies the segment: `value`, `defaultValue` and `onValueChange` use it. Unique within the control. */
  value: string;
  /** The segment's text. Keep it to one short word; every segment gets the width of the longest. */
  label: string;
  /** Shows the segment but keeps it from being pressed. */
  disabled?: boolean | undefined;
  /** Not a prop. A segment switches a view in place; to go to another page, use `Tabs` or links in a `<nav>`. */
  href?: { 'A segment switches a view in place. To navigate, use links in a <nav>': never };
}

Claim sources