Design System / Components / Segmented control

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.

Live Preview Flip Props
System
iOS
Width
Full
Segments
4
State
selected

Usage

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.

iOS · light
11:39● ● ● ●
Spotify
Your Identity Done
Add Credit Card
Add Email Address
Add Username
Android · light
11:39● ● ● ●
Spotify
Your Identity Done
Add Credit Card
Add Email Address
Add Username

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.

iOS · pill
1 — Track · 36 high · 999 radius 2 — Segment · 28 high · 12 px padding 3 — Inner padding · 4 px 4 — Gap · 4 px
Android · tabs
1 — Tab row · 48 high 2 — Segment · 12 px horizontal padding 3 — Bottom rule · 1 px 4 — Indicator · 2 px underline

States

Every segment has four interactive states. Selected and pressed both have visible feedback; disabled is clearly non-interactive.

iOS · Pill

Default
Selected
Pressed
Disabled

Android · Tab

Default
Selected
Pressed
Disabled

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.

Width = Full · iOS
Width = Fluid · iOS
3 segments · iOS

Dimensions

Track and segment heights are platform-fixed. Segments grow horizontally; height never changes.

Track · iOSHeight 36 · radius 999 · inner padding 4 · gap 4
Segment · iOSHeight 28 · radius 999 · horizontal padding 12 · min-width 40
Track · AndroidHeight 48 · radius 0 · 1 px bottom rule #CCCCCC
Segment · AndroidHeight 48 · horizontal padding 12 · min-width 60
Icon-only · iOSPill 28×28 · glyph 16×16
Max segments4 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 · defaultSF Pro Bold · 15 / 20 · letter-spacing −0.24
iOS · pressedSF Pro Text · 14 · transient (matches the press shadow)
iOS · colorDefault #404040 · Selected #000 · Disabled #999
Android · defaultRoboto Medium · 16 / 20 · letter-spacing 0.01em
Android · colorDefault + 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 · darkrgba(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 padding4 px on every side of the track
iOS gap between segments4 px
Android segment padding0 12 px · no gap, no dividers
External marginMin 16 px from screen edges, 12 px from adjacent components
With section header12 px below header, 16 px above content

Motion

Selection should feel instant. The indicator slides; the surface does not bounce.

iOS selectionBackground fade + shadow lift · 120 ms · cubic-bezier(0.16, 1, 0.3, 1)
iOS pressedShadow expand + label resize · 120 ms · ease-out
Android indicatorUnderline scale-X · 180 ms · ease-out · slides between segments
DisabledNo 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.

Do

Three peer choices, short labels — fits the track without truncating, single tap reveals the result.

Don't

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.

Do

Single-word labels, comparable lengths — the eye reads them as peers.

Don't

Multi-word labels of unequal length — the long one wraps or pushes neighbors off the row.

Not for navigation

Use a segmented control to switch views of the same data. If tapping a segment loads a new screen or fundamentally changes context, you want tabs (Android), a TabView (iOS), or a router-driven navigation stack instead.

↓ same chart, three resolutions
Do

Same content, different view — the data stays put, only the lens changes.

↓ three different screens
Don't

Different destinations — these are app sections, not views. Use a tab bar on iOS or bottom navigation on Android.

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
Do

iOS app uses the iOS pill. Looks like home.

iOS app
Don't

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.

Do

One segmented control owns the time-range decision for the whole screen.

Don't

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 in role="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 update aria-selected.
  • For icon-only segments, provide an aria-label on every button — the glyph is decorative.
  • Disabled segments use the native disabled attribute and skip the focus ring.
  • Hit areas are at least 44 × 44 on iOS and 48 × 48 on 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.

iOS · 3 segments · Full width
<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.

Android · 3 segments · Full width
<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.

SegmentedControl.tsx
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.

Example · time-range filter
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"
segmentsArray of { id, label, icon?, disabled? } · 2–4 entries
valuestring · id of the active segment (controlled)
onChange(id: string) => void · fires on click and keyboard nav
iconOnlyboolean · hide labels; segment label becomes aria-label
ariaLabelstring · required · names the tablist for screen readers