Pro

Segmented control

Base UI radio group with the Tabs' look: a clear hairline track and one flat pill that glides to the checked option. Arrow keys and form values come with the group.

Household spending

WeekMonthQuarterYear
$912.55Shared groceries, Oct 1 to Oct 23

Content widths, in a form

MonthlyYearly, save 15%Custom

$228 billed once a year. Custom terms come with the Household plan.

Installation

With UI Polish connected, ask your agent:

Add the UI Polish segmented control to this project.

Or with the shadcn CLI:

npx shadcn@latest add https://uipolish.com/r/segmented-control.json
Package@base-ui/react
Brings inTabs, Text

Picks one value from two to five short options, shown side by side. It's a radio group in a clear hairline track, with one flat pill that glides to the checked option. It looks and moves exactly like Tabs; only the meaning differs (a value, not a panel). It's built on Base UI's RadioGroup and Radio, which supply arrow keys, Tab, disabled options and form submission.

TSX
<SegmentedControl aria-label="Date range" value={range} onValueChange={setRange}>  <Segment value="week">Week</Segment>  <Segment value="month">Month</Segment>  <Segment value="quarter">Quarter</Segment>  <Segment value="year">Year</Segment></SegmentedControl>

When to use it

  • A choice that changes what's shown or saved: a chart's date range, a billing period, list or board view.
  • Two to five options with labels of one or two words, where seeing every option at once helps.

When not to

  • Switching between views of one object with their own panels: use tabs.
  • More than five options, or long labels: use a select.
  • On or off: use a switch.

Props

PropOnWhat it does
aria-label / aria-labelledbySegmentedControlNames the choice ("Date range")
value, onValueChangeSegmentedControlControlled
defaultValueSegmentedControlUncontrolled starting value
widthsSegmentedControlequal (default): each option as wide as the widest. content: each as wide as its label
nameSegmentedControlForm field name; the checked value submits
disabledbothThe whole group, or one option
aria-labelSegmentNeeded when the option is an icon only

The group is inline-grid: give it width: 100% to stretch, and equal options share the width.

States

StateWhat you seeHow
RestMuted label
HoverLabel in the text color@media (hover: hover)
PressNothing of its own: the pill moving over is the answer
CheckedLabel in the text color, pill behind it[data-checked]
FocusA line just inside the option's pill, instantly:focus-visible
Disabled50% opacity, no hover, skipped by the arrow keysdisabled → [data-disabled]
Before JSThe checked option paints its own pill:not([data-ready])

Every option keeps the same weight in every state, so checking one never changes a width.

Layout

It shares the Tabs' look through the --ui-track-* tokens, so a change to one changes both:

  • --ui-track-edge: a clear track, no fill, inside a hairline of --ui-border-strong, the same edge as the fields and buttons it lines up with. With no fill of its own it sits as well on a card as on the page.
  • --ui-track-inset: 4px from the track's edge to the pill on every side.
  • --ui-track-pill: the checked option's flat pill (--ui-fill-strong), no shadow and no edge, fully round, the same 14px label padding as a tab.

At md the options and the pill are 32px inside the 4px inset, so the ends are concentric and 4 + 32 + 4 is the 40px control height: the group lines up with buttons and fields in a row. The whole control is measured, track included. At sm it's 32px whole and the inset drops by 1px (26px pill, 12px padding, small text): a 4px inset would leave the pill too tight around its label. That size is the one difference from Tabs, which come in one size. On touch screens each option's hit area grows to 40px without moving anything.

The control is never wider than its container. Labels stay whole, so when the options don't fit, they scroll sideways inside the track (no scrollbar shows), and the pill scrolls with them.

Motion

WhatDurationEasing
Pill stretches a little to the checked option and draws in behind it--ui-dur-glideboth edges --ui-ease-glide, the leading one sooner
Label color--ui-dur-instant--ui-ease-color

The flight is the Tabs pill's own: one function, flyPill in tabs/pill-flight.ts, plays both, so they never drift apart. The edge facing the new option leads and the far edge follows, with no overshoot, so the pill never swings past the group's ends, and it keeps its height in flight.

The pill is one absolutely positioned, contained element. Position is a translate; its width is set directly, because scaling would squash its round ends. It's measured from fractional bounding boxes, so equal-width options that don't land on whole pixels still get a pill that fits exactly. It's placed before first paint without animating (until then the checked option paints its own pill), re-placed without animating on resize and when fonts load, and if the value changes mid-move it starts from where it is on screen. Under prefers-reduced-motion: reduce it jumps.

Accessibility

  • role="radiogroup" around role="radio" options with aria-checked. Tab enters on the checked option; Left and Right (or Up and Down) move and check, wrapping, and skip disabled options.
  • Each option is the radio itself, so clicks, focus and the accessible name (its label, or aria-label for an icon) are all the option's. A hidden input beside it carries the value into forms when name is set.

Before / after

Before: buttons with an active class. Arrow keys do nothing, the choice isn't announced as selected, and the background jumps between options.

TSX
{["week", "month", "year"].map((r) => (  <button key={r} className={range === r ? "active" : ""} onClick={() => setRange(r)}>{r}</button>))}

After:

TSX
<SegmentedControl aria-label="Date range" value={range} onValueChange={setRange}>  <Segment value="week">Week</Segment>  <Segment value="month">Month</Segment>  <Segment value="year">Year</Segment></SegmentedControl>