HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.2. Its API may change.

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.

Depends on
  • @ui-sdk/core
  • @ui-sdk/react
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 }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-segmented-control.json

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

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.

@layer ui-sdk;

@import "tailwindcss";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/ui/ui-sdk-segmented-control"
<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.

SegmentedControl
├── SegmentedControlItem
└── SegmentedControlItem

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

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

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 }

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

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 }

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

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 }

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

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 on the control turns it all off and submits nothing. On one item, the arrow keys skip it.

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 }

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

"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 }

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.

"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 }

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

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 }

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

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 }
KeyAction
TabMoves 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.
SpaceChecks the focused item.
EnterSubmits its form.

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.

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.

PropTypeDefault
value
T | null–
defaultValue
T | null–
onValueChange
(value: T | null, details) => void–
size
"sm" | "default" | "lg""default"
orientation
"horizontal" | "vertical""horizontal"
loop
booleantrue
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllerFrom createSegmentedControlController().
SegmentedControlController–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="segmented-control"Target the segmented control in CSS.
data-sizeThe size: sm, default or lg.
data-orientationhorizontal or vertical.
data-disabledPresent when disabled.
data-readonlyPresent when read-only.
data-requiredPresent when required.
PropTypeDefault
value
string–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="segmented-control-item"Target an item in CSS.
data-checkedPresent on the checked item.
data-uncheckedPresent on the other items.
data-icon-onlyPresent when the item holds only an icon.
data-disabledPresent when disabled.