Skip to the content

ComponentsForm

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"

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.

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.

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.

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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
TabMoves 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.
SpaceChooses the focused segment when it is not chosen yet.

API

SegmentedControl

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

SegmentedControl props
PropTypeDefaultDescription
asChildboolean
defaultValuestringThe chosen segment's value at the start, when it controls itself.
dir"ltr" | "rtl"The reading direction. The provider's dir by default.
disabledbooleanDisables every segment.
fluidbooleanfalseFills the width of its container; the segments share it equally.
formstring
loopbooleanWhether the arrow keys wrap from the last segment to the first. True by default.
namestringThe form field name; the chosen value is submitted with the form.
onValueChange((value: string) => void)Called with the chosen segment's value.
requiredbooleanThe 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.
valuestringThe chosen segment's value, when you control it.

SegmentedControlItem

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

SegmentedControlItem props
PropTypeDefaultDescription
asChildboolean
checkedboolean
requiredboolean
valuestring | nullThe 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accent-foregroundtext
--backgroundborder, background
--field-disabledborder, background
--field-disabled-foregroundtext
--invalidborder
--mutedborder, background
--muted-foregroundtext
--ringoutline
--secondary-hover-foregroundtext