DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. SegmentedControl

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/ui

When 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { SegmentedControl, SegmentedControlItem } from "@tessinaui/ui";
example.tsx
<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

PropTypeDefaultDescription
valuestring | null—Controlled selection. null selects nothing
defaultValuestring | 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
fullWidthbooleanfalseStretch the track across its container, or size it to its content
tone"default" | "on-color"inheritedPaints from the surrounding plate's ink. Falls back to the enclosing Surface
disabledbooleanfalseDisables the whole group
readOnlybooleanfalseFocusable and announced, but the value cannot change
requiredbooleanfalseMarks the group required for form validation
errorMessageReactNode—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
namestring—Serializes the selection under this name
formstring—Associates the hidden inputs with a form by id
showItemBorderbooleanfalseHairline separators, suppressed either side of the selection
dir"ltr" | "rtl"inheritedWriting direction; also drives the arrow-key axis

SegmentedControlItem props

PropTypeDefaultDescription
valuestringrequiredUnique value identifying this segment
labelstring—Visible label. Segments take a label, not children
leadingIconReact.ReactNode—Icon before the label
trailingIconReact.ReactNode—Icon after the label. May be set alongside leadingIcon
disabledbooleanfalseDisables this segment only
aria-labelstring—Required when the segment has no visible label

SegmentedControlSkeleton props

PropTypeDefaultDescription
variant / size / rounded / orientation / fullWidth—matches the controlMirror the control the skeleton stands in for
optionsnumber3Number of placeholder segments
labelsnumber[]—Per-segment label length in characters
showLeadingIconbooleanfalseMirror each segment's leading icon
showTrailingIconbooleanfalseMirror each segment's trailing icon
labelstring"Loading", Arabic under dir="rtl"What the region announces while loading

Data attributes

AttributeOnValue
data-slotevery partsegmented-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-tonetrackThe resolved axis value
data-full-widthtrackPresent when segments stretch
data-readonlytrackPresent while read-only
data-checked / data-uncheckedsegmentSelection state
data-disabledtrack, segmentPresent while disabled
data-thumb-pendingtrackPresent 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-readythumbPresent 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.

SizeItemTrackItem + track (touch)Use case
xs32px36px44px + 48pxToolbars, chart ranges, inspector rows
sm36px40px44px + 48pxSecondary controls, settings rows
md40px48px44px + 52pxDefault
lg48px56px48px + 56pxProminent controls with larger text

Accessibility

  • The track is a radiogroup with an explicit aria-orientation; each segment is a radio. Give the group a name with aria-label or aria-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 behind prefers-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 raised and ghost the 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) and outline (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 by aria-checked, so it is never conveyed by the fill alone; the fill pairing is the one Tabs already ships, and changing it is a shared-token decision rather than a per-component retune. Reach for solid or outline where 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

BeforeNow
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.

TokenizerCalendar

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesRoundedWith iconsIcon-onlyFull widthVerticalSeparatorsDisabledControlled and empty selectionIn a formErrorOn colorLoadingRTLAPI ReferenceSegmentedControl propsSegmentedControlItem propsSegmentedControlSkeleton propsData attributesSizesAccessibilityMigrating from the previous API