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
pnpm add @tessinaui/uiWhen to use
- One choice from a small, fixed set that changes what a nearby view shows: a chart's range, a list's sort, a screen's mode. Two to five segments is the working range.
- Seeing every option at once is part of the point. If people need the whole set in view to choose, a segmented control shows it; a menu hides it.
- A choice that applies immediately, with no Save or Apply step after it.
- Short labels. A label is a word or two, and a segment shrinks and
ellipsizes before it wraps. Denser sets (seven or eight time ranges) shorten
labels to about four characters and drop to
xs; they never scroll. - Matching content in every segment: all labels, or all icons. Icon-only
segments each need an
aria-label.
When not to use
- To navigate or swap whole panels. A segmented control is an input, not navigation. Use Tabs when the choice swaps panels of content or the set has to scroll.
- For filters that combine or grow. Use a Chip group when several can be on, new ones get added, or the row scrolls.
- For a list that outgrows the row. Use Picker past about eight options, or when the options come from data that grows.
- When several options can be on at once. Use ToggleGroup.
- When the items perform actions. Use ButtonGroup. A two-segment control and a split action button are the same shape with opposite meaning: one holds a state, the other fires a command. Do not mix them in one control.
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.
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. The selected segment is the solid
inverse fill in every variant, so the variants differ in the track, not in how
a selection reads. solid is the same thumb on the accent. outline frames
the track with 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. A disabled control dims once: the whole track fades, and every label keeps its own ink under that fade.
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. Name the control from its visible label
with aria-labelledby: it is a radiogroup, which a <label htmlFor> cannot
point at.
Error
errorMessage explains why the selection is invalid — typically a required choice
left empty. It renders below the track in a polite live region mounted from first
render, marks the radiogroup aria-invalid, and joins the message into its
aria-describedby, so tabbing back into the group repeats why. The track takes the
error outline. Submit without choosing here.
On color
On a coloured plate the track is the field's 8% well and the selected segment is the inverse chip — the plate's own ink, labelled with the neutral that contrasts it — so every label clears 4.5:1 on all six plates.
Loading
RTL
Under dir="rtl" the first segment sits on the right, the thumb follows the selection, and ← selects the next segment. Unset, the control follows the page: on an RTL page the layout and the arrow keys both mirror, with no dir to pass.
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; on touch every segment is at least 44×44px |
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 |
errorMessage | ReactNode | — | Why the selection is invalid. Renders below the track, announced politely, marks the radiogroup aria-invalid and is merged into its aria-describedby. Hidden while readOnly |
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", Arabic under dir="rtl" | 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-strut (an invisible, zero-height sizer after each horizontal segment), 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.
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 density under a mouse or trackpad. On touch — a phone,
or a tablet at any width — every size lifts its items to a 44px touch target,
so xs and sm are compact under a fine pointer and finger-sized under a
finger. A vertical rounded="full" track keeps its ends concentric with the
taller touch segments.
A row needs 44px a segment for that. In a narrower column — six time ranges in a sidebar, say — the segments share the width equally and narrow below 44px, never overlapping and never spilling out of the track; past that, shorten the labels or drop a size.
| Size | Item | Track | Item + track (touch) | 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 | 48px | 56px | 48px + 56px | 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. - On touch — a phone, or a coarse pointer at any width — every segment is at least 44×44px, at every size, whenever its row has 44px a segment to give. A narrower row shares its width instead, so its segments narrow below 44px and never overlap. Under a mouse every segment is at least 24×24px.
- 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.
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.