Segmented control
A mutually-exclusive choice across 2–4 options at the same level of importance. iOS uses a pill track; Android uses a bordered tab row. One tap, one result, no surprises.
Playground
Toggle any combination — system, width, type, segment count, state. Changes update the live specimen.
iOSFull4selectedUsage
Use a segmented control to switch the same screen between 2–4 mutually-exclusive views — Details / Contacts / Settings, Day / Week / Month, Personal / Work. Reach for tabs (full-width, scrolling) when you have 5+ options or when each tab is a distinct destination.
Types
Three content variants × two platform systems. Pick the one that matches the platform you're shipping for; never mix iOS pill with Android tabs in the same app.
iOS · Pill
Filled white pill on a gray track. Selected segment uses a subtle drop shadow; pressed switches to liquid-glass shadow with 14px label.
Android · Tabs
48 px tab row with a 1 px bottom rule. Selected segment is indicated by a black underline that animates in, never by a background fill.
Icon & Text
Pair a 16 px glyph with the label when the icon adds a fast-read cue. Use sparingly — too many icons crowds the track.
Anatomy
A segmented control is a track plus 2–4 segments. The track shapes the surface; segments hold a label, optionally an icon, and own one of four states.
States
Every segment has four interactive states. Selected and pressed both have visible feedback; disabled is clearly non-interactive.
iOS · Pill
Android · Tab
Sizes
Full stretches segments to share the available width — use when the control owns the row. Fluid sizes segments to content — use when the control sits in a toolbar with other actions.
Dimensions
Track and segment heights are platform-fixed. Segments grow horizontally; height never changes.
| Track · iOS | Height 36 · radius 999 · inner padding 4 · gap 4 |
|---|---|
| Segment · iOS | Height 28 · radius 999 · horizontal padding 12 · min-width 40 |
| Track · Android | Height 48 · radius 0 · 1 px bottom rule #CCCCCC |
| Segment · Android | Height 48 · horizontal padding 12 · min-width 60 |
| Icon-only · iOS | Pill 28×28 · glyph 16×16 |
| Max segments | 4 on both platforms — beyond 4 use scrolling tabs |
Typography
Labels are short, sentence-case, and never wrap. iOS uses SF Pro; Android uses the platform Roboto stack.
| iOS · default | SF Pro Bold · 15 / 20 · letter-spacing −0.24 |
|---|---|
| iOS · pressed | SF Pro Text · 14 · transient (matches the press shadow) |
| iOS · color | Default #404040 · Selected #000 · Disabled #999 |
| Android · default | Roboto Medium · 16 / 20 · letter-spacing 0.01em |
| Android · color | Default + Selected #000 · Disabled #B3B3B3 |
Color styles
Tokens used by the control. Dark-theme overrides live alongside light values.
| iOS track · light | #E5E5E5 |
|---|---|
| iOS segment selected · light | #FFFFFF + shadow 0 3 8 rgba(0,0,0,.08) |
| iOS pressed · light | #FFFFFF + liquid-glass 0 8 16 rgba(0,0,0,.10) |
| iOS track · dark | rgba(120,120,128,0.24) |
| iOS segment selected · dark | #636366 |
| Android rule · light | #CCCCCC |
| Android indicator · light | #000000 |
| Android indicator · dark | #FFFFFF |
Spacing
Internal rhythm and external breathing room.
| iOS inner padding | 4 px on every side of the track |
|---|---|
| iOS gap between segments | 4 px |
| Android segment padding | 0 12 px · no gap, no dividers |
| External margin | Min 16 px from screen edges, 12 px from adjacent components |
| With section header | 12 px below header, 16 px above content |
Motion
Selection should feel instant. The indicator slides; the surface does not bounce.
| iOS selection | Background fade + shadow lift · 120 ms · cubic-bezier(0.16, 1, 0.3, 1) |
|---|---|
| iOS pressed | Shadow expand + label resize · 120 ms · ease-out |
| Android indicator | Underline scale-X · 180 ms · ease-out · slides between segments |
| Disabled | No transition — state is static |
Match the platform
iOS pill and Android tabs are not interchangeable styles — they're the same semantic spelled in two visual languages. Never ship the iOS pill on Android or the bordered tabs on iOS.
| Property | iOS · Pill | Android · Tabs |
|---|---|---|
| Track shape | Pill, fully rounded (999 px), 4 px inner padding | Rectangular, 1 px bottom rule, no radius |
| Selection cue | Filled white pill on gray track | 2 px black underline beneath the active segment |
| Dividers | None — 4 px gap between segments | None — segments share the bottom rule |
| Typography | SF Pro Bold · 15 / 20 · −0.24 tracking | Roboto Medium · 16 / 20 · 0.01em tracking |
| Don't | Mix segment widths · drop a primary action inside | Add a background fill to the selected segment |
Segment count
Segmented controls work for 2–4 mutually-exclusive options. Beyond 4, the labels truncate, the touch targets shrink, and you should reach for tabs instead.
Three peer choices, short labels — fits the track without truncating, single tap reveals the result.
Six segments crushed onto one row — labels fight for space, the touch targets are below the 44 px minimum.
Label length
Labels never wrap. Keep each segment to one or two short words and balance the lengths so no segment dominates.
Single-word labels, comparable lengths — the eye reads them as peers.
Multi-word labels of unequal length — the long one wraps or pushes neighbors off the row.
Don't mix systems
Pick one system per platform and ship it everywhere. iOS users expect the pill; Android users expect the tab row. Mixing breaks the platform's mental model and signals an unfinished port.
iOS app uses the iOS pill. Looks like home.
iOS app shipping the Android pattern. Reads as foreign and probably broken.
One source of truth
Don't pair a segmented control with another global filter doing the same job. Pick the right control for the cardinality, then commit.
One segmented control owns the time-range decision for the whole screen.
Segmented control AND a dropdown driving the same state — one of them is redundant and the other will go out of sync.
Accessibility
Segments are tabs in a tablist. Wire them up with the right roles and your users get keyboard nav and screen-reader announcements for free.
- Wrap the track in
role="tablist"and each segment inrole="tab". - Set
aria-selected="true"on the active segment,"false"on the rest. - Manage focus with a single
tabindex="0"on the active segment; arrow keys move between segments and updatearia-selected. - For icon-only segments, provide an
aria-labelon every button — the glyph is decorative. - Disabled segments use the native
disabledattribute and skip the focus ring. - Hit areas are at least
44 × 44on iOS and48 × 48on Android, regardless of the visual segment size.
HTML · iOS pill
Use a div.sc-ios track with role="tablist". Each segment is a button.sc-ios-btn with a data-state. Add .sc-full to make segments share the row, or omit it for fluid sizing.
<div class="sc-ios sc-full" role="tablist" aria-label="View"> <button class="sc-ios-btn" role="tab" data-state="selected" aria-selected="true">Day</button> <button class="sc-ios-btn" role="tab" data-state="default" aria-selected="false">Week</button> <button class="sc-ios-btn" role="tab" data-state="default" aria-selected="false">Month</button> </div>
HTML · Android tabs
Same shape, different track. div.sc-android draws the bottom rule; each button.sc-android-btn contributes a 2 px underline indicator when selected.
<div class="sc-android sc-full" role="tablist" aria-label="View"> <button class="sc-android-btn" role="tab" data-state="selected" aria-selected="true">Details</button> <button class="sc-android-btn" role="tab" data-state="default" aria-selected="false">Contacts</button> <button class="sc-android-btn" role="tab" data-state="default" aria-selected="false">Settings</button> </div>
State modifiers
Selection, press, and disabled are all driven by the data-state attribute on each segment button. Move the value as the user interacts — the CSS does the rest.
data-state="default" | Resting · ready for input |
|---|---|
data-state="selected" | The currently active segment · only one per track |
data-state="pressed" | Active touch / mouse-down state · iOS shows liquid-glass shadow |
data-state="disabled" | Non-interactive · also set the native disabled attribute |
React · <SegmentedControl />
Wrap the markup in a controlled component that owns the active index and emits change. Switch the system prop based on the user's platform.
import { useId, useState, KeyboardEvent } from "react"; type System = "ios" | "android"; type Width = "full" | "fluid"; export interface Segment { id: string; label: string; icon?: React.ReactNode; disabled?: boolean; } export interface SegmentedControlProps { system?: System; // "ios" | "android" width?: Width; // "full" | "fluid" (iOS only) segments: Segment[]; value: string; // id of the active segment onChange(id: string): void; iconOnly?: boolean; // drops labels, requires aria-label per segment ariaLabel: string; } export function SegmentedControl({ system = "ios", width = "full", segments, value, onChange, iconOnly = false, ariaLabel, }: SegmentedControlProps) { const trackClass = system === "ios" ? `sc-ios ${width === "full" ? "sc-full" : "sc-fluid"}` : "sc-android sc-full"; const btnClass = system === "ios" ? "sc-ios-btn" : "sc-android-btn"; function onKey(e: KeyboardEvent<HTMLDivElement>) { const idx = segments.findIndex(s => s.id === value); if (e.key === "ArrowRight") onChange(segments[(idx + 1) % segments.length].id); if (e.key === "ArrowLeft") onChange(segments[(idx - 1 + segments.length) % segments.length].id); } return ( <div className={trackClass} role="tablist" aria-label={ariaLabel} onKeyDown={onKey}> {segments.map(seg => { const selected = seg.id === value; return ( <button key={seg.id} role="tab" type="button" className={btnClass} data-state={seg.disabled ? "disabled" : selected ? "selected" : "default"} aria-selected={selected} aria-label={iconOnly ? seg.label : undefined} tabIndex={selected ? 0 : -1} disabled={seg.disabled} onClick={() => !seg.disabled && onChange(seg.id)} > {seg.icon} {!iconOnly && <span>{seg.label}</span>} </button> ); })} </div> ); }
React · usage
Hook the component up to local state and switch the platform based on a runtime detector.
import { useState } from "react"; import { SegmentedControl } from "./SegmentedControl"; import { isAndroid } from "./platform"; export function TimeRangeFilter() { const [range, setRange] = useState("day"); return ( <SegmentedControl system={isAndroid() ? "android" : "ios"} ariaLabel="Time range" value={range} onChange={setRange} segments={[ { id: "day", label: "Day" }, { id: "week", label: "Week" }, { id: "month", label: "Month" }, ]} /> ); }
Props · API
system | "ios" | "android" · default "ios" |
|---|---|
width | "full" | "fluid" · iOS only · default "full" |
segments | Array of { id, label, icon?, disabled? } · 2–4 entries |
value | string · id of the active segment (controlled) |
onChange | (id: string) => void · fires on click and keyboard nav |
iconOnly | boolean · hide labels; segment label becomes aria-label |
ariaLabel | string · required · names the tablist for screen readers |