A circular radio control for single-select groups. 39 variants across Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError.
Style × State × Size × isSelected × isError), vector ring + dot at all three sizes, semantic layer names, a real Pressed state, and Disabled + unselected coverage. Pressed now folds into State and error is an orthogonal isError boolean — matching Button v4.0 and Select Item. Only Code Connect registration remains.Radio Buttons appear in Radio Button with Label groups — see the Radio Button with Label preview for the composed form row.
container → circle (fill) + ring (stroke) — so tokens propagate to all three. Carries its own selection, error, and interaction styling; the raster imgContainer export is gone.Style × State × Size × isSelected × isError. No conditional axes and no invalid combinations: pressed lives in State alongside Disabled, error is its own boolean, and booleans are lowercase true/false. Matches Button v4.0 and Select Item.container / circle / ring) and the Check style is a vector icon instance rather than a drawn shape.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (unselected) | Yes | Yes | State=Default, isSelected=false | Empty ring. Resting state for every unpicked option in a group. |
| Selected | Yes | Yes | State=Default, isSelected=true | Brand ring + inner dot, both #005CE5. Exactly one option per group carries this. |
| Pressed | Yes | Yes | State=Pressed | Ring and dot darken to #2340A9. Combines with either isSelected value. Derived at runtime from the touch interaction — not a parameter you pass. |
| Disabled | Yes | Yes | State=Disabled | Muted ring and dot. Ships at both isSelected values, so a disabled group can render its unselected options. |
| Error | Yes | Yes | isError=true | Ring and dot switch to #D61B2C. Orthogonal to State — combines with Default and Pressed. |
| Selected — check style | Yes | Yes | Style=Check, isSelected=true | Filled circle + vector checkmark instead of a dot. For single-select list rows; only meaningful when selected. |
- Code Connect mappings not registered. Previously blocked by the sparse matrix and the raster large radio — both resolved in v2.0. Registration is now unblocked, but the SwiftUI / Compose mappings are not yet wired. C7 · Code Connect Linkability
- v2.0: Variant matrix restructured into orthogonal axes —
Style(Default/Check) ×State(Default/Pressed/Disabled) ×Size(Large/Medium/Small) ×isSelected×isError.selectedno longer conflates selection with modifier states, and no property is conditional on another. (C2) - v2.0: Pressed folded into
State, error split into an orthogonalisErrorboolean — removes the illegalDisabled + Pressedcombination the old two-axis model allowed, and matches the Button v4.0 / Select Item pattern. (C2/C4) - v2.0: Large radio rebuilt as layered vectors —
container→circle(ELLIPSE fill) +ring(ELLIPSE stroke), both on#005CE5. The pre-renderedimgContainerraster is gone, so token changes now propagate to every size. (C3) - v2.0: Misleading
.base/checkboxframe renamed — layers are nowcontainer/circle/ring. (C6) - v2.0: Pressed state added —
State=Pressedcovers touch feedback across Default, Check, and error variants (#2340A9). Focused is N/A on mobile: touch has no focus ring. (C5) - v2.0:
Disabled+isSelected=falseadded at all three sizes — a disabled radio group can now render its unselected options, which was previously impossible. (C5) - v2.0: Boolean variant values lowercased to
true/false, matching the C2 convention. (C2) - v2.0: Checkmark promoted to a vector icon instance (
Checkmark→Grid) rather than a drawn shape. (C6) - v2.0:
Disableddeliberately excludesisError— confirmed as an intentional omission, not a gap. A locked control offers the user no path to resolve a validation error, so the combination has no affordance and ships no variants. (C5) - v2.0:
Style=Checkconfirmed as intentional — retained rather than retired, now shipping as a vector icon instance with full Default / Pressed / Disabled coverage. Usage guidance added to disambiguate it from Checkbox. (C6)
- Register Code Connect mapping to
EBRadioButton. With the orthogonal axes, vector rebuild, and state coverage all shipped, wire the Figma properties (Style, State, Size, isSelected, isError) 1:1 to the SwiftUI / Compose API. NoteisSelected/isErrorare variant enums, so Code Connect maps them viafigma.enum()→Bool. Docs
- Split properties into orthogonal axes. v2.0: Applied — and taken further than proposed.
Style×State×Size×isSelected×isError, with pressed folded intoStateand error split out as its own boolean. No invalid combinations remain. Property - Rebuild the large radio as vector layers. v2.0: Applied — every size is now
container→circle+ringvector ellipses. Tokens flow to all three sizes. Asset - Rename the internal frame. v2.0: Applied —
.base/checkboxis gone; layers arecontainer/circle/ring. Rename - Add pressed states. v2.0: Applied —
State=Pressedmaps touch feedback to#2340A9. Focused was dropped as N/A on mobile. State - Document when to use Check vs Default. v2.0: Applied — the Check style was reviewed and deliberately retained rather than retired. Usage guidance now disambiguates it from Checkbox: Check for single-select list rows, Default (filled dot) for forms where real Checkboxes appear alongside. Docs
- Record
Disabled + isErroras an intentional omission. v2.0: Applied — reviewed and confirmed deliberate. A locked control offers no path to resolve a validation error, so the combination ships no variants by design. Docs
Pick a state, size, and style to preview. The same matrix would otherwise be 12 static cells; here it's controlled.
| Role | Token | Token | Value |
|---|---|---|---|
| Unselected | border | main/radio-button/color/default/unselected/border | #D7E0EF |
| Selected | bg (fill + ring) | main/radio-button/color/default/selected/bg | #005CE5 |
| — | border | main/radio-button/color/default/selected/border | #005CE5 |
| — | inner dot / checkmark | main/radio-button/color/default/selected/icon | #FFFFFF |
| Disabled | bg | main/radio-button/color/disabled/selected/bg | #C2CFE5 |
| — | border | main/radio-button/color/disabled/selected/border | #C2CFE5 |
| — | inner icon | main/radio-button/color/disabled/selected/icon | #FFFFFF |
| Error | border (unselected) | main/radio-button/color/error/unselected/border | #D61B2C |
| — | bg (selected) | main/radio-button/color/error/selected/bg | #D61B2C |
| — | border (selected) | main/radio-button/color/error/selected/border | #D61B2C |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:radio:1.0.0") }
| Figma Property | SwiftUI | Compose |
|---|---|---|
| selected=unselected/selected | selected: Bool | selected: Bool |
| selected=disabled | state=disabled (combine w/ selected) | .disabled(true) |
| selected=error | state=error | state: .error |
| size=small/large | size: EBRadioSize | .controlSize(.small) |
| style=filled/checkmark | (retire — pick filled only) | — |
| style=default (unselected/error) | implicit from selected=false | — |
| — | onToggle | onChange: (Bool) -> Void |
// Basic selection EBRadioButton(selected: $isSelected) // Large size with error state EBRadioButton(selected: $isSelected, state: .error) .controlSize(.large) // Disabled EBRadioButton(selected: true) .disabled(true)
// Basic selection EBRadioButton(selected = checked, onCheckedChange = { checked = it }) // Large size with error state EBRadioButton( selected = checked, onCheckedChange = { checked = it }, size = EBRadioSize.Large, state = EBRadioState.Error ) // Disabled EBRadioButton( selected = true, onCheckedChange = { }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Role | Inherit radio semantics via Toggle(isOn:) with radio style | Use Modifier.selectable(role = Role.RadioButton) |
| Selected state | .accessibilityAddTraits(.isSelected) | selected = true in semantics |
| Group label | Wrap options in a .accessibilityElement(children: .contain) with group label | Use Modifier.selectableGroup() on parent |
| Tap target | Radio is 20px; wrap in 44pt hit area | Wrap in 48dp hit area |
| Error announcement | Pair with a label and announce the error message after the label | Use semantics { error(...) } |
Do
Use <code>Style=Check</code> for single-select list rows — settings pickers, option lists, and full-width choice rows where the checkmark reads as "this one is chosen" (the iOS picker convention).
Don't
Don't use <code>Style=Check</code> in a form where real Checkboxes appear alongside it — the two become visually indistinguishable. Use <code>Style=Default</code> (filled dot) there; the dot is the universal single-select affordance.
Do
Use <code>isError=true</code> together with <code>State=Default</code> or <code>Pressed</code> to flag a failed validation the user can still act on.
Don't
Don't expect <code>Disabled + isError</code> — it ships no variants by design. A locked control gives the user no way to resolve the error, so surface the message elsewhere (e.g. a Callout or Alert) rather than on the radio.
Do
Pair <code>Size</code> to the surrounding text scale — Large (24) for 18px labels, Medium (20) for 16px, Small (16) for dense lists.
Don't
Don't mix sizes within a single radio group — every option in one group should share the same <code>Size</code> and <code>Style</code>.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | Internal frame named .base/checkbox instead of .base/radio. |
| C2 | Variant & Property Naming | Requires Rework | selected mixes selection with modifiers; style is conditional. |
| C3 | Token Coverage | Requires Rework | Large variants are raster — tokens don't flow to the large size. |
| C4 | Native Mappability | Ready | Maps to Toggle / RadioButton with custom style. |
| C5 | Interaction State Coverage | Requires Rework | No pressed or focused states. |
| C6 | Asset & Icon Quality | Requires Rework | Checkmark style conflicts with Checkbox visually; large radio is a pre-rendered image. |
| C7 | Code Connect Linkability | Needs Refinement | Blocked by C2. Clean mapping lands after prop split. |
Style (2) × State (3) × Size (3) × isSelected (2) × isError (2) = 72 theoretical. 39 ship. Check is only meaningful when selected, and Disabled deliberately excludes isError — a locked control offers no path to resolve the error. Grouped below by Style × State; each row covers all three sizes.
| Style | State | isSelected | isError | Count | Notes |
|---|---|---|---|---|---|
| Default | Default | false | false | 3 | Empty ring · all sizes |
| Default | Default | true | false | 3 | Brand ring + dot #005CE5 |
| Default | Default | false | true | 3 | Error ring #D61B2C |
| Default | Default | true | true | 3 | Error ring + dot |
| Default | Pressed | false | false | 3 | Pressed ring #2340A9 |
| Default | Pressed | true | false | 3 | Pressed ring + dot |
| Default | Pressed | false | true | 3 | Pressed error ring |
| Default | Pressed | true | true | 3 | Pressed error ring + dot |
| Default | Disabled | false | false | 3 | Muted empty ring |
| Default | Disabled | true | false | 3 | Muted ring + dot |
| Check | Default | true | false | 3 | Brand fill + vector checkmark |
| Check | Pressed | true | false | 3 | Pressed fill + checkmark |
| Check | Disabled | true | false | 3 | Muted fill + checkmark |
Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError, replacing the old sparse selected × size × style matrix.
RestructuredState, error split into an orthogonal isError boolean. Removes the illegal Disabled + Pressed combination and aligns with Button v4.0 / Select Item.
Resolvedcontainer → circle + ring ellipses on #005CE5. The pre-rendered imgContainer raster is gone; tokens now propagate to all three sizes.
Resolved.base/checkbox replaced by container / circle / ring; the Check style now nests a vector Checkmark icon instance.
ResolvedState=Pressed (#2340A9) covers touch feedback, and Disabled + isSelected=false now exists at all sizes so disabled groups can render unselected options. Focused dropped as N/A on mobile.
ResolvedTrue/False → true/false, matching the C2 convention. Radio Button is now the reference for boolean vocabulary in the system.
ResolvedisError=true while still painted pressed-navy, which also removed Check's pressed state. Renamed back to State=Pressed, isError=false; label and paint now agree.
ResolvedDisabled + isError=true not covered — 9 slots absent. Likely intentional, but undocumented. Confirm and record.
Openselected mixes selection with modifier states; style is only meaningful when selected. Open.base/checkbox + checkmark style overlaps with Checkbox. Open