SegmentedControl
A radio group wearing a track — two to five mutually exclusive options, seen whole, with a sliding thumb, four track styles, and full keyboard and form participation
Playground
Installation
pnpm add @tessinaui/uiUsage
import { SegmentedControl, SegmentedControlItem } from "@tessinaui/ui";<SegmentedControl aria-label="Range" defaultValue="week">
<SegmentedControlItem value="day" label="Day" />
<SegmentedControlItem value="week" label="Week" />
<SegmentedControlItem value="month" label="Month" />
</SegmentedControl>Segments take a label, not children: the label is the only elastic part of a
segment, so it has to be a single node the component can shrink and ellipsize.
When to use one
A segmented control is an input, not navigation. Reach for it when someone picks one of a small, fixed set of options that changes what a nearby view shows — a chart's range, a list's sort, a screen's mode — and when seeing all the options at once is part of the point.
Two to five segments is the working range. Apps do push to seven or eight for
time ranges, and the way they do it is instructive: the labels shorten to four
characters, the size drops to xs, and the segments keep shrinking. They never
scroll. If your options need to scroll, they are not a segmented control:
| Instead you want | When |
|---|---|
Tabs | The choice swaps whole panels of content, or the set scrolls |
Chip group | Filters that can be combined, added to, or scrolled |
Picker | More than about eight options, or a list that grows |
ToggleGroup | Several options can be on at once |
ButtonGroup | The items perform actions rather than holding a selection |
That last row matters more than it looks. A two-segment control and a split action button are the same shape and opposite semantics — one holds a state, the other fires a command. Do not mix them in one control.
Examples
Default
Three labelled segments and an initial selection. The thumb slides between them and snaps instantly for anyone who prefers reduced motion.
Variants
Four track systems, chosen by what the control sits on rather than by taste.
raised is the default: a filled well with a light thumb lifted out of it.
solid fills the thumb with the accent instead. outline carries the selection
on a border, which is the treatment that survives forced-colors mode. ghost
drops the track entirely — use it over media, or when the control pins to a
scrolling view, where a filled well reads as a hole in the page.
Sizes
Rounded
Track, thumb and segments step together, so the thumb's corners stay concentric with the track's at every value.
With icons
Icons lead the label, always. Both slots can be filled at once — the trailing one is for a glyph that reports something about the option, never a second thing to press: a segment has one hit target, so anything parked there is an affordance nobody can click.
Icon-only
An icon announces nothing on its own, so every icon-only segment needs an
aria-label; the component warns in development when one is missing. Never mix
icon-only and labelled segments in the same control.
Full width
Segments are equal-width in both modes; fullWidth only decides whether the
track hugs its labels (the default) or fills the row. Stretch it for a mode
switch that governs the whole screen; leave it inline for a range selector
beside a chart or a view switcher in a toolbar.
Vertical
Separators
Hairlines divide the unselected segments and disappear on the first segment and either side of the selection, so no line ever runs into the selected chip's edge. They are deliberately faint — a separator only has to hint at a division the labels already imply — and they work on the filled well as well as on a bordered track.
Disabled
Across 72 sampled app screens, not one disabled a segment — apps omit the option instead. Disable a single segment only when it has to stay visible for the set to make sense; disable the whole control while its data is in flight.
Controlled and empty selection
value={null} is a real state: nothing is selected, and the control is still
announced and still reachable by Tab. onValueChange also reports whether a
press or the keyboard caused the change.
In a form
name serializes the selection through hidden radio inputs, so the control
works in a plain form with no adapter. readOnly keeps a value visible and
focusable while blocking the change.
On color
Loading
Sizes
Every grouped control insets its items from the container edge by the same
shared scale — 2px at xs/sm, 4px from md up — so a ToggleGroup, a
SegmentedControl and a Stepper sitting side by side read as one family. Track
height is therefore the item height plus twice that inset.
The ladder below is the desktop density. Below the md breakpoint every
size lifts its items to a 44px touch target, so xs and sm are compact on a
pointer device and finger-sized on a phone.
| Size | Item (md+) | Track (md+) | Item + track (below md) | Use case |
|---|---|---|---|---|
xs | 32px | 36px | 44px + 48px | Toolbars, chart ranges, inspector rows |
sm | 36px | 40px | 44px + 48px | Secondary controls, settings rows |
md | 40px | 48px | 44px + 52px | Default |
lg | 44px | 52px | 44px + 52px | Prominent controls with larger text |
Accessibility
- The track is a
radiogroupwith an explicitaria-orientation; each segment is aradio. Give the group a name witharia-labeloraria-labelledby. - Keyboard: Tab enters the group once and leaves it once — the group is a single tab stop. Arrow keys move the selection (in a radio group focus and selection travel together), following the writing direction in RTL. Home and End jump to the first and last enabled segments. Space selects; Enter deliberately does not, matching Radio and Checkbox.
- With nothing selected, the tab stop falls on the first enabled segment, so an unset control is never stranded outside the tab sequence.
- Disabled segments are skipped by every one of those keys.
- The sliding thumb is decorative: it is
aria-hidden, and its transition is gated behindprefers-reduced-motion, so it jumps rather than travels for anyone who asked for less motion. - Touch targets are at least 44×44px below the
mdbreakpoint at every size. - Known contrast limit, recorded rather than retuned. On
raisedandghostthe thumb separates from the track by 1.26:1 in light and 1.31:1 in dark — below the 3:1 that WCAG 1.4.11 asks of a non-text state indicator.solid(14.23:1 / 14.5:1) andoutline(a 3.03:1 border) clear it outright. On the other two the selection is carried additionally by a label ink step that clears AA in both states and byaria-checked, so it is never conveyed by the fill alone; the fill pairing is the oneTabsalready ships, and changing it is a shared-token decision rather than a per-component retune. Reach forsolidoroutlinewhere the selection must be legible from the fill alone. - Verified with VoiceOver on Safari. Walking the control announces "Range radio group", then "Day radio button, 1 of 3", "Week selected radio button, 2 of 3", "Month radio button, 3 of 3"; activating a segment announces the new selection. Chromium's accessibility tree matches, and axe passes in both themes. NVDA and JAWS remain untested — neither runs on macOS.
API Reference
SegmentedControl props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | — | Controlled selection. null selects nothing |
defaultValue | string | null | — | Initial selection for uncontrolled use |
onValueChange | (value: string, details: { reason: "press" | "keyboard" }) => void | — | Fires with the new value and why it changed |
variant | "raised" | "solid" | "outline" | "ghost" | "raised" | Track and selection system |
size | "xs" | "sm" | "md" | "lg" | "md" | Desktop density; every size holds the 44px floor below md |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Corner radius for track, thumb and segments together |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction |
fullWidth | boolean | false | Stretch the track across its container, or size it to its content |
tone | "default" | "on-color" | inherited | Paints from the surrounding plate's ink. Falls back to the enclosing Surface |
disabled | boolean | false | Disables the whole group |
readOnly | boolean | false | Focusable and announced, but the value cannot change |
required | boolean | false | Marks the group required for form validation |
name | string | — | Serializes the selection under this name |
form | string | — | Associates the hidden inputs with a form by id |
showItemBorder | boolean | false | Hairline separators, suppressed either side of the selection |
dir | "ltr" | "rtl" | inherited | Writing direction; also drives the arrow-key axis |
SegmentedControlItem props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Unique value identifying this segment |
label | string | — | Visible label. Segments take a label, not children |
leadingIcon | React.ReactNode | — | Icon before the label |
trailingIcon | React.ReactNode | — | Icon after the label. May be set alongside leadingIcon |
disabled | boolean | false | Disables this segment only |
aria-label | string | — | Required when the segment has no visible label |
SegmentedControlSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
variant / size / rounded / orientation / fullWidth | — | matches the control | Mirror the control the skeleton stands in for |
options | number | 3 | Number of placeholder segments |
labels | number[] | — | Per-segment label length in characters |
showLeadingIcon | boolean | false | Mirror each segment's leading icon |
showTrailingIcon | boolean | false | Mirror each segment's trailing icon |
label | string | "Loading" | What the region announces while loading |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-slot | every part | segmented-control, segmented-control-item, segmented-control-thumb, segmented-control-skeleton |
data-variant / data-size / data-orientation / data-tone | track | The resolved axis value |
data-full-width | track | Present when segments stretch |
data-readonly | track | Present while read-only |
data-checked / data-unchecked | segment | Selection state |
data-disabled | track, segment | Present while disabled |
data-thumb-pending | track | Present until the thumb has measured. Segments paint their own selection for that first frame — and in server-rendered HTML — so the control is never shown with nothing selected |
data-ready | thumb | Present once the thumb has measured. The thumb is opacity-0 without it, so it never paints at the track's origin on the first frame |
Those last two are the thumb's first-paint handshake, and they are the hooks to
use if you restyle the thumb. Keep the data-[ready] opacity gate — a thumb
made unconditionally visible flashes at the track's top-left corner before the
first measurement lands, and again on every server-rendered page.
Migrating from the previous API
| Before | Now |
|---|---|
variant="pill" | rounded="full" — it was a radius wearing a variant's name |
variant="filled" | variant="solid" |
variant="outline" (the default) | Still variant="outline", but the default is now raised |
onValueChange={(v) => …} | Unchanged; a second details argument is available |
A control that never named a variant keeps working and changes appearance: it
was a bordered track, and it is now the raised well-and-thumb treatment. Pass
variant="outline" explicitly to keep the old look.