A 2-option segmented control where the selected segment is filled brand-blue and the unselected segment carries a brand-blue outline.
selected=first / selected=second). That doesn't scale to 3+ options and forces consumers to detach for any non-binary case. Recommendation: ship one EBSegmentedControl with a segments: [Segment] array and a selectedIndex prop. The visual treatment (filled-on-selected / outlined-on-unselected) stays. Also add Pressed, Focused, and Disabled state coverage — currently only Default exists.Used in filters, list-view toggles ("List / Grid"), and binary-mode pickers ("Send / Receive", "Daily / Monthly"). Sits inline above a content area and switches what's rendered below.
segments: [Segment] data prop would let one component cover every count.selected=first|second is positional. Should be selectedIndex: Int or selected: SegmentID in code.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Tap unselected segment | Yes | Yes | Switches selectedIndex | Pressed state on the tapped segment isn't modeled — should preview the destination on touch-down, commit on touch-up. |
| Tap selected segment | N/A | N/A | No-op | Re-tapping the selected segment should be a no-op (or, optionally, reset to a default). Not spec'd. |
| Pressed | N/A | N/A | Not modeled | Need a transient pressed treatment (e.g. 8% darken on bg, segment scale 0.97) for tap feedback. |
| Focused (keyboard / a11y) | N/A | N/A | Not modeled | No visible focus ring. Important for accessible web embeds and Android TV. |
| Disabled | N/A | N/A | Not modeled | No disabled state spec'd — a control inside a form needs one. |
- Component locks 2 segments via positional variants.
selected=first|secondmeans consumers stuck with exactly two segments. List-view toggles, period pickers, and filter chips frequently want 3+ segments. Replace withsegments: [Segment]+selectedIndex: Intso one component covers every count. C1 · Layer Structure & Naming - Positional naming in the selection axis.
first/secondare not semantic — they refer to position, not value. If the segments are reordered, the prop value's meaning shifts. UseselectedIndex: Int(orselected: Segment.ID) so the prop is stable. C2 · Variant & Property Naming - No Pressed / Focused / Disabled state. Only Default state is modeled. A toggle control inside a form needs disabled coverage; an accessible web embed needs a keyboard focus ring; tap targets need a transient pressed treatment. C5 · Interaction State Coverage
- Segment label is baked text — no icon or count slot. Real-world segmented controls often need a leading icon ("⊞ Grid" / "≡ List") or a trailing count ("Unread (12)"). The component doesn't expose slots for either; consumers detach. C4 · Native Mappability
- Equal-width 168 segments are hardcoded. Total 336 × 40. For 3-segment use cases the math breaks (112 each? 168/n?). A data-driven control would distribute available width across the segment count automatically. C1 · Layer Structure & Naming
- Code Connect mappings not registered. Blocked on the data-driven restructure — once
segmentsis an array, Code Connect can register a singleEBSegmentedControlmapping instead of per-variant entries. C7 · Code Connect Linkability
- Promote to a data-driven
EBSegmentedControl. Target API:EBSegmentedControl(segments: [Segment], selectedIndex: Int, onChange: (Int) -> Void)whereSegment = { id, label, icon?, badge? }. Covers 2-, 3-, 4-segment cases with no per-count variants in Figma. Property - Rename the selected axis to be data-stable. Use
selectedIndex: Int(or aselected: SegmentIDstring) so the value's meaning doesn't shift if segments are reordered. Rename - Add Pressed, Focused, and Disabled state coverage. Pressed: subtle darken on bg + 0.97 scale on touch-down. Focused: 2px outline (offset 2) for keyboard nav. Disabled: 40% opacity on the whole row, pointer-events: none. State
- Expose leading-icon + trailing-badge slots per segment. Common segmented-control patterns prefix segments with an icon ("⊞ Grid") or append a count badge ("Unread (12)"). Both need named slots so consumers don't detach. Slot
- Distribute segment width evenly via flex. Don't hardcode 168 per segment. Use flex:1 on each so a 3-segment control divides 336 / 3 = 112 each automatically, with no Figma rework needed. Property
- Document the A11y model. Role:
tablistwith each segment astab, currently selected getsaria-selected="true". Keyboard: ←/→ to move, Space/Enter to commit. VoiceOver: "Tab, 1 of 2, selected". A11y
336 × 40 control with two 168-wide segments. Selected segment fills with brand-blue (#005CE5) and shows white label; the other segment carries a 1.5px brand-blue outline and a brand-blue label.
Selected segment fills with brand-blue; unselected segment carries an outline of the same brand-blue.
| Role | Token | Selected | Unselected |
|---|---|---|---|
| Background | toggle-segmented-control/color/{state}/bg | #005CE5 | #FFFFFF |
| Border | toggle-segmented-control/color/{state}/border | none | #005CE5 |
| Label | toggle-segmented-control/color/{state}/label | #FFFFFF | #005CE5 |
Today's 2-variant axis maps to a single data-driven control once promoted. The table reflects the proposed shape.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| selected=first / second | selectedIndex: Int | selectedIndex: Int |
| (implicit 2 segments) | segments: [Segment] | segments: List |
| segment label | Segment.label: String | Segment.label: String |
| (no leading icon today) | Segment.icon: Image? | Segment.icon: ImageVector? |
| (no count badge today) | Segment.badge: Int? | Segment.badge: Int? |
| (no callback today) | onSelectionChange: (Int) -> Void | onSelectionChange: (Int) -> Unit |
| Requirement | iOS | Android |
|---|---|---|
| Role | Use a custom UISegmentedControl-style trait. Each segment is a .button with .isSelected trait set on the active one. | Apply Role.RadioButton on each segment + Modifier.selectableGroup on the row. |
| Selection announce | Use accessibilityValue = "Option 1, 1 of 2, selected". Avoid relying on visual color alone. | Use contentDescription = "Option 1, 1 of 2, selected". |
| Keyboard / focus | ←/→ arrows move selection; Space/Enter commits. Visible focus ring is mandatory in non-touch contexts. | DPAD ←/→ moves selection; Enter commits. Use Modifier.focusable() with a visible indicator. |
| Disabled | Set .disabled(true) on the row; segments are not focusable. | Set enabled = false; segments not focusable; opacity 0.4. |
| Tap target | ≥ 44 × 44 hit area per segment. With segments at 168 × 40, vertical hit area needs 4 px extension via .contentShape. | ≥ 48 dp touch target. Use Modifier.minimumInteractiveComponentSize() when segments fall below. |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Requires Rework | Two manual variants for what should be a data-driven segments array. |
| C2 | Variant & Property Naming | Needs Refinement | selected=first|second is positional. Use selectedIndex: Int. |
| C3 | Token Coverage | Needs Refinement | Hex values consistent but no toggle-segmented-control/* token namespace registered. |
| C4 | Native Mappability | Needs Refinement | No leading-icon or count-badge slots per segment. Common segmented-control patterns can't be expressed. |
| C5 | Interaction State Coverage | Requires Rework | Only Default. No Pressed / Focused / Disabled. |
| C6 | Asset & Icon Quality | Not Applicable | No assets in v1 (text only). When icon slot lands, must accept vector instances. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until data-driven restructure lands. |
Two variants on a single selected axis (first | second). Same visual treatment; only which segment is filled differs.
| # | selected | Size | Node |
|---|---|---|---|
| 1 | first | 336 × 40 | 27:30930 |
| 2 | second | 336 × 40 | 27:30935 |
selected axis. Used in list-view toggles, period pickers, filter chips. DocumentedEBSegmentedControl with segments array + selectedIndex prop. Add Pressed / Focused / Disabled state coverage. Opensegments: [Segment] array. Openselected=first|second not stable under reorder. Use selectedIndex: Int. Open