# Segmented control

> A segmented control on Vercel's UI SDK with a sliding indicator, three sizes, icon-only items, full width, vertical layout and radio-group keyboard.

Canonical: https://hextaui.com/docs/ui-sdk-segmented-control
Last updated: 2026-11-14

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.2 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-segmented-control/demo.tsx"
import { IconLayoutGrid, IconLayoutKanban, IconList } from "@tabler/icons-react"

import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlDemo() {
  return (
    <SegmentedControl aria-label="View" defaultValue="list">
      <SegmentedControlItem value="list">
        <IconList aria-hidden="true" />
        List
      </SegmentedControlItem>
      <SegmentedControlItem value="grid">
        <IconLayoutGrid aria-hidden="true" />
        Grid
      </SegmentedControlItem>
      <SegmentedControlItem value="board">
        <IconLayoutKanban aria-hidden="true" />
        Board
      </SegmentedControlItem>
    </SegmentedControl>
  )
}

export { UiSdkSegmentedControlDemo }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-segmented-control.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.2 @ui-sdk/core@0.1.0-beta.2 cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-segmented-control.tsx"
"use client"

import * as React from "react"
import * as SegmentedControlPrimitive from "@ui-sdk/react/segmented-control"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type SegmentedControlSize = "sm" | "default" | "lg"

type SegmentedControlProps<T extends string = string> = React.ComponentProps<
  typeof SegmentedControlPrimitive.SegmentedControl<T>
> & {
  size?: SegmentedControlSize
}

function SegmentedControl<T extends string = string>({
  className,
  size = "default",
  children,
  ...props
}: SegmentedControlProps<T>) {
  const [ready, setReady] = React.useState(false)

  React.useEffect(() => {
    let second = 0
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() => setReady(true))
    })
    return () => {
      cancelAnimationFrame(first)
      cancelAnimationFrame(second)
    }
  }, [])

  return (
    <SegmentedControlPrimitive.SegmentedControl<T>
      data-size={size}
      data-ready={ready ? "" : undefined}
      className={mergeClassName(
        "group/segmented-control relative isolate inline-flex w-fit max-w-full min-w-0 items-stretch gap-0.5 rounded-lg bg-muted p-[3px] text-muted-foreground data-[orientation=vertical]:flex-col forced-colors:border",
        className
      )}
      {...props}
    >
      {(state) => (
        <>
          <SegmentedControlIndicator />
          {typeof children === "function" ? children(state) : children}
        </>
      )}
    </SegmentedControlPrimitive.SegmentedControl>
  )
}

function SegmentedControlIndicator({
  className,
  ...props
}: React.ComponentProps<
  typeof SegmentedControlPrimitive.SegmentedControlIndicator
>) {
  return (
    <SegmentedControlPrimitive.SegmentedControlIndicator
      className={mergeClassName(
        "pointer-events-none absolute top-0 left-0 -z-1 h-(--segmented-control-indicator-height) w-(--segmented-control-indicator-width) translate-x-(--segmented-control-indicator-left) translate-y-(--segmented-control-indicator-top) rounded-md bg-background ring-(length:--hairline) ring-foreground/5 group-data-ready/segmented-control:ease-out-quint motion-safe:group-data-ready/segmented-control:transition-[translate,width,height] motion-safe:group-data-ready/segmented-control:duration-300 motion-reduce:transition-none dark:bg-input/50 forced-colors:border forced-colors:border-[Highlight]",
        className
      )}
      {...props}
    />
  )
}

function isIconOnly(children: React.ReactNode) {
  const nodes = React.Children.toArray(children)
  return (
    nodes.length > 0 &&
    nodes.every(
      (node) =>
        React.isValidElement(node) &&
        (typeof node.type !== "string" || node.type === "svg")
    )
  )
}

function SegmentedControlItem({
  className,
  children,
  ...props
}: React.ComponentProps<
  typeof SegmentedControlPrimitive.SegmentedControlItem
>) {
  const iconOnly = typeof children !== "function" && isIconOnly(children)

  return (
    <SegmentedControlPrimitive.SegmentedControlItem
      data-icon-only={iconOnly ? "" : undefined}
      className={mergeClassName(
        "relative inline-flex h-8 min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color,box-shadow,scale] duration-150 ease-out-quint outline-none select-none group-data-[orientation=vertical]/segmented-control:flex-none group-data-[orientation=vertical]/segmented-control:justify-start group-data-[size=lg]/segmented-control:h-9 group-data-[size=lg]/segmented-control:px-4 group-data-[size=sm]/segmented-control:h-7 group-data-[size=sm]/segmented-control:px-2.5 group-data-[size=sm]/segmented-control:text-xs focus-visible:ring-3 focus-visible:ring-focus-ring active:duration-75 data-icon-only:aspect-square data-icon-only:flex-none data-icon-only:px-0 group-data-[orientation=vertical]/segmented-control:data-icon-only:justify-center data-readonly:cursor-default motion-safe:not-data-checked:not-data-readonly:active:scale-[0.97] pointer-coarse:h-10 pointer-coarse:group-data-[size=lg]/segmented-control:h-11 pointer-coarse:group-data-[size=sm]/segmented-control:h-9 data-checked:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 group-data-[size=sm]/segmented-control:[&_svg:not([class*='size-'])]:size-3.5 [@media(hover:hover)]:not-data-readonly:hover:text-foreground",
        className
      )}
      {...props}
    >
      {children}
    </SegmentedControlPrimitive.SegmentedControlItem>
  )
}

const createSegmentedControlController =
  SegmentedControlPrimitive.createSegmentedControlController

export {
  SegmentedControl,
  SegmentedControlItem,
  createSegmentedControlController,
}
export type { SegmentedControlProps, SegmentedControlSize }
export type {
  SegmentedControlController,
  SegmentedControlState,
  SegmentedControlItemState,
  SegmentedControlOrientation,
} from "@ui-sdk/react/segmented-control"
```

Update the import paths to match your project setup.

## Usage

UI SDK adds a little CSS of its own in a cascade layer named `ui-sdk`. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

```css
@layer ui-sdk;

@import "tailwindcss";
```

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

```tsx
<SegmentedControl aria-label="View" defaultValue="list">
  <SegmentedControlItem value="list">List</SegmentedControlItem>
  <SegmentedControlItem value="grid">Grid</SegmentedControlItem>
</SegmentedControl>
```

A segmented control switches between a few views or settings. It works like a radio group, with one value and one tab stop, and the indicator behind the checked item slides to the next choice. Name it with `aria-label` or `aria-labelledby`. UI SDK marks it experimental in 0.1.0-beta.2.

## Composition

```text
SegmentedControl
├── SegmentedControlItem
└── SegmentedControlItem
```

The control draws its own indicator, so you only add items.

## Examples

### Sizes

`size` is `sm`, `default` or `lg`. Items grow on touch screens.

```tsx title="components/examples/ui-sdk-segmented-control/sizes.tsx"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

const sizes = ["sm", "default", "lg"] as const

function UiSdkSegmentedControlSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      {sizes.map((size) => (
        <SegmentedControl
          key={size}
          aria-label={`Effort, ${size}`}
          size={size}
          defaultValue="balanced"
        >
          <SegmentedControlItem value="fast">Fast</SegmentedControlItem>
          <SegmentedControlItem value="balanced">Balanced</SegmentedControlItem>
          <SegmentedControlItem value="thorough">Thorough</SegmentedControlItem>
        </SegmentedControl>
      ))}
    </div>
  )
}

export { UiSdkSegmentedControlSizes }
```

### Icons

An item holding only an icon turns square. Name it with `aria-label`.

```tsx title="components/examples/ui-sdk-segmented-control/icons.tsx"
import {
  IconAlignCenter,
  IconAlignJustified,
  IconAlignLeft,
  IconAlignRight,
} from "@tabler/icons-react"

import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlIcons() {
  return (
    <SegmentedControl aria-label="Text alignment" defaultValue="left">
      <SegmentedControlItem value="left" aria-label="Align left">
        <IconAlignLeft aria-hidden="true" />
      </SegmentedControlItem>
      <SegmentedControlItem value="center" aria-label="Align center">
        <IconAlignCenter aria-hidden="true" />
      </SegmentedControlItem>
      <SegmentedControlItem value="right" aria-label="Align right">
        <IconAlignRight aria-hidden="true" />
      </SegmentedControlItem>
      <SegmentedControlItem value="justify" aria-label="Justify">
        <IconAlignJustified aria-hidden="true" />
      </SegmentedControlItem>
    </SegmentedControl>
  )
}

export { UiSdkSegmentedControlIcons }
```

### Full width

Give the control `w-full` and the items share its width.

```tsx title="components/examples/ui-sdk-segmented-control/full-width.tsx"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlFullWidth() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <p id="billing-period" className="m-0 text-sm font-medium">
        Billing period
      </p>
      <SegmentedControl
        aria-labelledby="billing-period"
        defaultValue="yearly"
        className="w-full"
      >
        <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
        <SegmentedControlItem value="yearly">Yearly</SegmentedControlItem>
      </SegmentedControl>
    </div>
  )
}

export { UiSdkSegmentedControlFullWidth }
```

### Vertical

`orientation="vertical"` stacks the items. Arrow keys on both axes still move between them.

```tsx title="components/examples/ui-sdk-segmented-control/vertical.tsx"
import {
  IconLayoutAlignBottom,
  IconLayoutAlignMiddle,
  IconLayoutAlignTop,
} from "@tabler/icons-react"

import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlVertical() {
  return (
    <SegmentedControl
      aria-label="Vertical alignment"
      orientation="vertical"
      defaultValue="middle"
    >
      <SegmentedControlItem value="top">
        <IconLayoutAlignTop aria-hidden="true" />
        Top
      </SegmentedControlItem>
      <SegmentedControlItem value="middle">
        <IconLayoutAlignMiddle aria-hidden="true" />
        Middle
      </SegmentedControlItem>
      <SegmentedControlItem value="bottom">
        <IconLayoutAlignBottom aria-hidden="true" />
        Bottom
      </SegmentedControlItem>
    </SegmentedControl>
  )
}

export { UiSdkSegmentedControlVertical }
```

### Disabled

`disabled` on the control turns it all off and submits nothing. On one item, the arrow keys skip it.

```tsx title="components/examples/ui-sdk-segmented-control/disabled.tsx"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlDisabled() {
  return (
    <div className="flex flex-col items-center gap-4">
      <SegmentedControl aria-label="Theme" defaultValue="system" disabled>
        <SegmentedControlItem value="light">Light</SegmentedControlItem>
        <SegmentedControlItem value="dark">Dark</SegmentedControlItem>
        <SegmentedControlItem value="system">System</SegmentedControlItem>
      </SegmentedControl>
      <SegmentedControl aria-label="Model" defaultValue="mini">
        <SegmentedControlItem value="mini">Mini</SegmentedControlItem>
        <SegmentedControlItem value="standard">Standard</SegmentedControlItem>
        <SegmentedControlItem value="pro" disabled>
          Pro
        </SegmentedControlItem>
      </SegmentedControl>
    </div>
  )
}

export { UiSdkSegmentedControlDisabled }
```

### Controlled

Pass `value` and update it in `onValueChange`. A controller from `createSegmentedControlController()` checks an item from anywhere.

```tsx title="components/examples/ui-sdk-segmented-control/controlled.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  SegmentedControl,
  SegmentedControlItem,
  createSegmentedControlController,
} from "@/components/ui/ui-sdk-segmented-control"

type Range = "day" | "week" | "month"

function UiSdkSegmentedControlControlled() {
  const [controller] = React.useState(() =>
    createSegmentedControlController<Range>()
  )
  const [range, setRange] = React.useState<Range | null>("week")

  return (
    <div className="flex flex-col items-center gap-3">
      <SegmentedControl<Range>
        aria-label="Range"
        value={range}
        onValueChange={(value) => setRange(value)}
        controller={controller}
      >
        <SegmentedControlItem value="day">Day</SegmentedControlItem>
        <SegmentedControlItem value="week">Week</SegmentedControlItem>
        <SegmentedControlItem value="month">Month</SegmentedControlItem>
      </SegmentedControl>
      <div className="flex items-center gap-2">
        <Button
          size="sm"
          variant="outline"
          onClick={() => controller.setValue("month")}
        >
          Show month
        </Button>
        <p className="m-0 text-xs text-muted-foreground">
          {`Showing ${range ?? "nothing"}`}
        </p>
      </div>
    </div>
  )
}

export { UiSdkSegmentedControlControlled }
```

### Form

Each item holds a hidden native radio, so `name` submits the checked value, `required` waits for a choice, and a reset returns to the default.

```tsx title="components/examples/ui-sdk-segmented-control/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlForm() {
  const [saved, setSaved] = React.useState<string | null>(null)

  return (
    <form
      className="flex flex-col items-start gap-4"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSaved(`theme=${data.get("theme")}, density=${data.get("density")}`)
      }}
    >
      <div className="flex flex-col gap-2">
        <p id="form-theme" className="m-0 text-sm font-medium">
          Theme
        </p>
        <SegmentedControl
          aria-labelledby="form-theme"
          name="theme"
          defaultValue="system"
          required
        >
          <SegmentedControlItem value="light">Light</SegmentedControlItem>
          <SegmentedControlItem value="dark">Dark</SegmentedControlItem>
          <SegmentedControlItem value="system">System</SegmentedControlItem>
        </SegmentedControl>
      </div>
      <div className="flex flex-col gap-2">
        <p id="form-density" className="m-0 text-sm font-medium">
          Density
        </p>
        <SegmentedControl
          aria-labelledby="form-density"
          name="density"
          defaultValue="comfortable"
        >
          <SegmentedControlItem value="compact">Compact</SegmentedControlItem>
          <SegmentedControlItem value="comfortable">
            Comfortable
          </SegmentedControlItem>
        </SegmentedControl>
      </div>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Save
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-sm text-muted-foreground">
        {saved ? `Saved ${saved}` : "Nothing saved yet."}
      </p>
    </form>
  )
}

export { UiSdkSegmentedControlForm }
```

### Read-only

A read-only control keeps its value and still submits. Arrow keys move focus without checking anything.

```tsx title="components/examples/ui-sdk-segmented-control/read-only.tsx"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlReadOnly() {
  return (
    <SegmentedControl aria-label="Plan" defaultValue="team" readOnly>
      <SegmentedControlItem value="solo">Solo</SegmentedControlItem>
      <SegmentedControlItem value="team">Team</SegmentedControlItem>
    </SegmentedControl>
  )
}

export { UiSdkSegmentedControlReadOnly }
```

### Right to left

The items run from the right, and the indicator follows them.

```tsx title="components/examples/ui-sdk-segmented-control/rtl.tsx"
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"

function UiSdkSegmentedControlRtl() {
  return (
    <div dir="rtl" lang="ar">
      <SegmentedControl aria-label="العرض" defaultValue="list">
        <SegmentedControlItem value="list">قائمة</SegmentedControlItem>
        <SegmentedControlItem value="grid">شبكة</SegmentedControlItem>
        <SegmentedControlItem value="board">لوحة</SegmentedControlItem>
      </SegmentedControl>
    </div>
  )
}

export { UiSdkSegmentedControlRtl }
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves into the control, onto the checked item or the first enabled one. |
| `←` `→` `↑` `↓` | Move to the next or previous item and check it, skipping disabled ones. They wrap unless loop is false. |
| `Space` | Checks the focused item. |
| `Enter` | Submits its form. |

## Accessibility

The control is a radio group and each item a radio, so screen readers announce the choice and its position. The indicator is presentational and skipped by screen readers.

The indicator only slides once the page is interactive, so it never animates in on load, and it doesn’t slide at all when reduced motion is on. In forced colors, the checked item keeps a visible border.

## API reference

Each part wraps the UI SDK part of the same name and accepts all of its props, including `render` and a `className` that can be a function of the part’s state.

### SegmentedControl

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `T \| null` | – |  |
| `defaultValue` | `T \| null` | – |  |
| `onValueChange` | `(value: T \| null, details) => void` | – |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `loop` | `boolean` | `true` |  |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `name` | `string` | – |  |
| `form` | `string` | – |  |
| `controller` | `SegmentedControlController` | – | From createSegmentedControlController(). |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="segmented-control"` | Target the segmented control in CSS. |
| `data-size` | The size: sm, default or lg. |
| `data-orientation` | horizontal or vertical. |
| `data-disabled` | Present when disabled. |
| `data-readonly` | Present when read-only. |
| `data-required` | Present when required. |

### SegmentedControlItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | – |  |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="segmented-control-item"` | Target an item in CSS. |
| `data-checked` | Present on the checked item. |
| `data-unchecked` | Present on the other items. |
| `data-icon-only` | Present when the item holds only an icon. |
| `data-disabled` | Present when disabled. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Calendar](https://hextaui.com/docs/calendar) - A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Combobox](https://hextaui.com/docs/combobox) - A filterable select with chips, groups and async results, in a popup that resizes as you type.
- [Date picker](https://hextaui.com/docs/date-picker) - A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
