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"ofrole="radio"buttons witharia-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-labeloraria-labelledby. Each segment is named by its text, or by itsaria-labelwhen 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
--ringoutline 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
| Key | Behaviour |
|---|---|
| Tab | Moves 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. |
| Space | Chooses the focused segment when it is not chosen yet. |
API
SegmentedControl
Renders Radix RadioGroup.Root and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
defaultValue | string | The chosen segment's value at the start, when it controls itself. | |
dir | "ltr" | "rtl" | The reading direction. The provider's dir by default. | |
disabled | boolean | Disables every segment. | |
fluid | boolean | false | Fills the width of its container; the segments share it equally. |
form | string | ||
loop | boolean | Whether the arrow keys wrap from the last segment to the first. True by default. | |
name | string | The form field name; the chosen value is submitted with the form. | |
onValueChange | ((value: string) => void) | Called with the chosen segment's value. | |
required | boolean | The 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. | |
value | string | The chosen segment's value, when you control it. |
SegmentedControlItem
Renders Radix RadioGroup.Item and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
checked | boolean | ||
required | boolean | ||
value | string | null | The 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.
| Token | Used for |
|---|---|
--accent-foreground | text |
--background | border, background |
--field-disabled | border, background |
--field-disabled-foreground | text |
--invalid | border |
--muted | border, background |
--muted-foreground | text |
--ring | outline |
--secondary-hover-foreground | text |