Segmented control
Free · MITiOS-style segmented control whose thumb springs to the chosen segment and squeezes while pressed, with sizes, icons, full-width mode, dividers that hide next to the thumb, and radio-group arrow keys.
Installation
$ npx shadcn@latest add https://goodui.dev/r/segmented-control.jsonThe file lands in components/goodui/segmented-control.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/segmented-control works too.
Usage
import { MapPin, Phone, Video } from "lucide-react"
import { SegmentedControl } from "@/components/goodui/segmented-control"
type AppointmentType = "in-person" | "video" | "phone"
export function AppointmentTypePicker({ value, onChange }: { value: AppointmentType; onChange: (v: AppointmentType) => void }) {
return (
<SegmentedControl
aria-label="Appointment type"
fullWidth
value={value}
onValueChange={(v) => onChange(v as AppointmentType)}
options={[
{ value: "in-person", label: "In person", icon: MapPin },
{ value: "video", label: "Video", icon: Video },
{ value: "phone", label: "Phone", icon: Phone },
]}
/>
)
}When to use
- Choosing one of 2–5 short options that apply right away: Monthly / Yearly pricing, In person / Video / Phone.
- View switchers in a toolbar: list, grid, map (icon-only with ariaLabel).
- Filters that change the content below without navigating, where every option should stay visible.
- Switching between panels of content: use tabs-indicator (role tablist with panels).
- An on/off setting: use switch.
- More than about five options or long labels: use select.
| If you need | Use |
|---|---|
| Pricing period toggle | size="lg" with two options |
| Form field that fills its row | fullWidth |
| Compact toolbar view switcher | size="sm" with icon-only options and ariaLabel |
| Options with icons and text | icon={MapPin} label="In person" |
Examples
Billing period with a rolling price
const [period, setPeriod] = React.useState("monthly")
<SegmentedControl
aria-label="Billing period"
size="lg"
value={period}
onValueChange={setPeriod}
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly (−20%)" },
]}
/>
<NumberFlow value={period === "yearly" ? 23 : 29} prefix="$" className="text-4xl font-semibold" />Icon-only view switcher
<SegmentedControl
aria-label="Layout"
size="sm"
defaultValue="list"
options={[
{ value: "list", icon: List, ariaLabel: "List view" },
{ value: "grid", icon: LayoutGrid, ariaLabel: "Grid view" },
{ value: "map", icon: MapIcon, ariaLabel: "Map view" },
]}
/>API reference
| Prop | Type | Default |
|---|---|---|
| options* | SegmentedControlOption[] | — |
| value Selected option value (controlled). | string | — |
| defaultValue Initially selected option when uncontrolled; defaults to the first enabled option. | string | — |
| onValueChange Called with the new value on click or arrow-key selection. | (value: string) => void | — |
| size Height: sm 32px, md 36px, lg 44px. | "sm" | "md" | "lg" | "md" |
| fullWidth Stretch to the container width with equal segments. | boolean | false |
| disabled Disables the whole control. | boolean | false |
Also accepts every <div> attribute.
Accessibility
| Tab | Focuses the selected segment (one tab stop for the whole control). |
| ArrowRight / ArrowDown | Selects and focuses the next enabled segment, wrapping around. |
| ArrowLeft / ArrowUp | Selects and focuses the previous enabled segment, wrapping around. |
| Home / End | Selects the first or last enabled segment. |
| Enter / Space | Selects the focused segment (native <button>). |
- The root is role="radiogroup" and each segment is a <button role="radio"> with aria-checked, using a roving tabindex.
- Give the group a name with aria-label (it is passed to the root). Icon-only options need ariaLabel.
- disabled on the root sets aria-disabled and disables every segment; disabled on an option skips it in keyboard navigation.
Motion
- The thumb is one shared-layout element that slides to the selected segment on a spring (stiffness 500, damping 32, mass 0.9).
- While pressed, the thumb squeezes to 0.95 scale, and an unselected segment's label to 0.96.
- Hairline dividers between unselected neighbours fade out in 200 ms next to the thumb, like iOS.
- Reduced motion: no squeeze, and the thumb jumps to the new segment.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- options is an array of { value, label?, icon?, ariaLabel?, disabled? }; icon takes the component (icon: MapPin), not an element (<MapPin />).
- onValueChange gives a string; cast it to your union type if you need to.
- Always pass aria-label on the control; there is no visible group label built in.
- Use it for options that apply immediately; for panels of content use tabs-indicator.
- Arrow keys select immediately (they do not just move focus), like native radio buttons.
FAQ
Segmented control or tabs?
A segmented control changes a setting or filter in place (radiogroup). Tabs switch between panels of content (tablist + tabpanel). Screen readers announce them differently.
Can it be uncontrolled?
Yes. Pass defaultValue (or nothing, then the first enabled option is selected) and read changes in onValueChange.
Related
Tabs whose indicator glides between tabs on a shared-layout spring (underline with hover highlight, pill, or segmented with icons), with arrow-key navigation and panels that slide in the direction of travel while the height eases to fit.
Six variants (underline, pill, segmented, enclosed folder tabs, soft, vertical line), a sliding, fading or static indicator, fade or directional slide panels, tones, sizes, shapes, shadows and orientation, with icons, count badges, arrow keys and a one-line items API.
iOS, Material 3, square, neo-brutalist, icon thumb and ON/OFF label variants with none, smooth, spring and bounce motion, six tones, three sizes, track shadows, sun/moon or check/x glyphs, label and description on either side, and a loading state.
Fully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.