DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 wantWhen
TabsThe choice swaps whole panels of content, or the set scrolls
Chip groupFilters that can be combined, added to, or scrolled
PickerMore than about eight options, or a list that grows
ToggleGroupSeveral options can be on at once
ButtonGroupThe 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.

SizeItem (md+)Track (md+)Item + track (below md)Use case
xs32px36px44px + 48pxToolbars, chart ranges, inspector rows
sm36px40px44px + 48pxSecondary controls, settings rows
md40px48px44px + 52pxDefault
lg44px52px44px + 52pxProminent 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.
  • Touch targets are at least 44×44px below the md breakpoint at every size.
  • 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.

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; 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
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
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"What the region announces while loading

Data attributes

AttributeOnValue
data-slotevery partsegmented-control, segmented-control-item, segmented-control-thumb, 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.

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.

SearchSelect

On this page

PlaygroundInstallationUsageWhen to use oneExamplesDefaultVariantsSizesRoundedWith iconsIcon-onlyFull widthVerticalSeparatorsDisabledControlled and empty selectionIn a formOn colorLoadingSizesAccessibilityAPI ReferenceSegmentedControl propsSegmentedControlItem propsSegmentedControlSkeleton propsData attributesMigrating from the previous API