A form row pairing a Radio Button with a text label. 39 variants across Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError — mirroring the Radio Button atom exactly.
Style, State, Size, isSelected and isError mirror the Radio Button atom across all 39 variants, with the radio paired to the label size.How the radio button with label appears in real product screens — each Yes / No answer on Fill Information, and each End option on a recurring payment, is a radio with its label.

_space_12 annotation instance that used to shim it has been removed, so the row maps to native padding rather than a spacer view."default - error" / "large - error" values are gone. Five orthogonal props — Style × State × Size × isSelected × isError — in the same order as the Radio Button atom, so native enum mapping is clean.Size=Large nests the 24 × 24 atom) instead of always instancing the small radio.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (unselected) | Yes | Yes | State=Default, isSelected=false | Empty ring, label #445C85. Resting state for every unpicked row. |
| Selected | Yes | Yes | State=Default, isSelected=true | Brand ring + dot #005CE5. Exactly one row per group carries this. |
| Pressed | Yes | Yes | State=Pressed | Radio darkens to #2340A9 and the label to #0A2757. Derived at runtime from the touch interaction, not passed as a parameter. |
| Disabled | Yes | Yes | State=Disabled | Radio and label both mute. Ships at both isSelected values, so a disabled group renders its unselected rows. |
| Error | Yes | Yes | isError=true | Radio switches to #D61B2C; the label is unchanged. Orthogonal to State — combines with Default and Pressed. |
| Selected — check style | Yes | Yes | Style=Check, isSelected=true | Filled circle + vector checkmark. For single-select list rows; only meaningful when selected. |
- v2.0:
sizeno longer encodes state — the compound"default - error"/"large - error"strings are gone, replaced by orthogonalSize(Large/Medium/Small) ×State(Default/Pressed/Disabled) ×isErrorprops. Native enum mapping is now clean. (C2) - v2.0: Radio size now pairs to label size —
Size=Largenests the 24 × 24 atom instead of hardcoding the 16 × 16 small radio into every variant. (C6) - v2.0: Full state coverage added —
isSelected,State=Pressed,State=Disabled, andisError, includingDisabled + isSelected=falseso a disabled radio group renders its unselected rows. 39 variants total. (C5) - v2.0:
_space_12spacer instance removed — the 12px gap is now real auto-layout spacing. The annotation instance (a#0500FFfill wrapping a Roboto"12"text node) no longer ships inside the component, and the layout maps to native padding rather than a spacer view. (C1/C4) - v2.0: Layer typo fixed —
icon-ofssetrenamed toicon-offsetacross all 39 variants. (C1) - v2.0: Schema aligned with the Radio Button atom — same five props, same property order, same 39-variant coverage. The wrapper can now express everything the atom can. (C2)
- v2.0: Check + Pressed mislabel corrected — three Check variants (
26184:2839 / 2845 / 2851) were labelledisError=truewhile painted pressed-navy, which also leftCheck + Pressed + isError=falsenon-existent. Renamed toisError=false; label and paint now agree and the wrapper matches the atom. (C5) - v2.0:
Disableddeliberately excludesisError— confirmed intentional, matching the atom. A locked row offers no path to resolve a validation error, so the combination ships no variants by design. (C5) - v2.0: Nesting the radio as a plain INSTANCE rather than a Figma Slot reviewed and accepted — the mirrored 39-variant matrix already exposes every atom state through
Style/State/Size/isSelected/isError, so a Slot would largely duplicate it. (C4)
- Make the whole row tappable. The label should be part of the tap target, not just the radio — standard for form rows and a meaningful hit-area win on mobile. Runtime behaviour, so it can't be expressed in Figma; document it in Accessibility instead. A11y
- Split the
sizeprop. v2.0: Applied — and taken further than proposed.Size×State×isError×isSelected×Style, with pressed folded intoState. The compound string values are gone. Property - Pair radio size to label size. v2.0: Applied —
Size=Largenests the 24 × 24 atom; each size pairs correctly instead of always instancing the small radio. Composition - Add disabled + selected variants. v2.0: Applied — full coverage across
isSelected,Pressed,Disabled, andisError, including the disabled-unselected rows. State - Adopt a Figma Slot for the nested radio. v2.0: Reviewed and closed as not needed — the mirrored 39-variant matrix already exposes every atom state, so a Slot would largely duplicate the variant surface. Revisit only if consumers need to nest a radio the matrix can't express. Slot
Component Playground
Layout
VStack
Typography
Colors
iOS — Swift Package Manager
.package(url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0")
Android — Gradle (Kotlin DSL)
implementation("com.eastblue.ds:radio:1.0.0")
Import
import EastBlueDS import com.eastblue.ds.radio.*
| Figma Property | SwiftUI | Compose |
|---|---|---|
| size=default / large | size: EBRadioSize | .controlSize(.small/.large) |
| "- error" suffix | isError: Bool | .ebError(true) |
| — | selected: Bool | selected: Bool |
| — | label: String | title: String |
| — | disabled: Bool | .disabled(true) |
| — | onToggle | onChange: (Bool) -> Void |
// Default row
EBRadioButtonWithLabel("Pay with GCash", selected: $selection == .gcash) {
selection = .gcash
}
// Large row with error
EBRadioButtonWithLabel("Option A", selected: isSelected)
.controlSize(.large)
.ebError(true)
// Disabled
EBRadioButtonWithLabel("Not available", selected: false)
.disabled(true) // Default row
EBRadioButtonWithLabel(
label = "Pay with GCash",
selected = selection == Option.Gcash,
onCheckedChange = { selection = Option.Gcash }
)
// Large row with error
EBRadioButtonWithLabel(
label = "Option A",
selected = isSelected,
onCheckedChange = { isSelected = it },
size = EBRadioSize.Large,
isError = true
)
// Disabled
EBRadioButtonWithLabel(
label = "Not available",
selected = false,
onCheckedChange = { },
enabled = false
) | Requirement | iOS | Android |
|---|---|---|
| Whole-row tap target | Wrap the row in a Button or tap gesture — label must be tappable, not just the radio | Apply Modifier.selectable(...) to the row |
| Group semantics | Wrap options in .accessibilityElement(children: .contain) | Use Modifier.selectableGroup() on parent |
| Role announcement | Radio Button role inherited from the atom | Role.RadioButton via selectable |
| Error announcement | Include error state in the option's accessibility label | Use semantics { error(...) } |
| Touch target size | Row should be at least 44pt tall | 48dp minimum |
Do
Use it when people pick one option from a short list they should see all at once.
Don't
Don’t use it when people can pick more than one — use Checkbox Item.
Do
Write the label as the option itself — “Pay with GCash”, “Yes”, “No”.
Don't
Don’t put helper text in the label; add a Subtext Message under the group instead.
Do
Keep one Size and one Style across a group.
Don't
Don’t mix Sizes or Styles within a single group of options.
Do
Make the whole row — radio and label — the tap target.
Don't
Don’t leave only the radio tappable; at 16–24px it is too small on its own.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names throughout: icon-offset (typo fixed in v2.0), text-container, #label. The _space_12 annotation shim was removed — the 12px gap is real auto-layout spacing. |
| C2 | Variant & Property Naming | Ready | Five orthogonal props — Style × State × Size × isSelected × isError — in the same order as the atom. The compound "default - error" values are gone; booleans are lowercase true/false. |
| C3 | Token Coverage | Ready | Label colors, gap, and padding all token-bound. The radio itself inherits the atom's token-bound vector fills and strokes. |
| C4 | Native Mappability | Ready | Maps to an HStack / Row of radio + label. Auto-layout spacing translates to native padding — no spacer view to reproduce since _space_12 was removed. |
| C5 | Interaction State Coverage | Ready | Full coverage: isSelected × State (Default / Pressed / Disabled) × isError, including Disabled + isSelected=false. Focused is N/A on mobile; Disabled + isError is a deliberate omission. |
| C6 | Asset & Icon Quality | Ready | Radio size now pairs to label size — Size=Large nests the 24 × 24 atom instead of always instancing the 16 × 16 small radio. |
Style (2) × State (3) × Size (3) × isSelected (2) × isError (2) = 72 theoretical. 39 ship — mirroring the Radio Button atom exactly. Check is only meaningful when selected, and Disabled deliberately excludes isError. Grouped below by Style × State; each row covers all three sizes (Large / Medium / Small).
| Style | State | isSelected | isError | Count | Notes |
|---|---|---|---|---|---|
| Default | Default | false | false | 3 | Empty ring + label |
| Default | Default | true | false | 3 | Brand ring + dot, brand label |
| Default | Default | false | true | 3 | Error ring #D61B2C |
| Default | Default | true | true | 3 | Error ring + dot |
| Default | Pressed | false | false | 3 | Pressed ring #2340A9, navy label |
| 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 + muted label |
| 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 |
View full Style × State × Size × isSelected × isError breakdown (39 rows)
| Style | State | Size | isSelected | isError | Node |
|---|---|---|---|---|---|
| Default | Disabled | Large | false | false | 26184:2713 |
| Default | Disabled | Medium | false | false | 26184:2719 |
| Default | Disabled | Small | false | false | 26224:4298 |
| Default | Pressed | Small | false | false | 26184:2725 |
| Default | Default | Large | false | false | 26184:2731 |
| Default | Pressed | Large | false | false | 26219:4049 |
| Default | Default | Medium | false | false | 26184:2737 |
| Default | Pressed | Medium | false | false | 26219:4078 |
| Default | Default | Small | false | false | 26184:2743 |
| Default | Disabled | Large | true | false | 26184:2749 |
| Default | Disabled | Medium | true | false | 26184:2755 |
| Default | Disabled | Small | true | false | 26184:2761 |
| Check | Disabled | Large | true | false | 26184:2767 |
| Check | Disabled | Medium | true | false | 26184:2773 |
| Check | Disabled | Small | true | false | 26184:2779 |
| Default | Default | Large | true | false | 26184:2785 |
| Default | Default | Medium | true | false | 26184:2791 |
| Default | Default | Small | true | false | 26184:2797 |
| Default | Pressed | Large | true | false | 26184:2803 |
| Default | Pressed | Medium | true | false | 26184:2809 |
| Default | Pressed | Small | true | false | 26184:2815 |
| Check | Default | Large | true | false | 26184:2821 |
| Check | Default | Medium | true | false | 26184:2827 |
| Check | Default | Small | true | false | 26184:2833 |
| Check | Pressed | Large | true | false | 26184:2839 |
| Check | Pressed | Medium | true | false | 26184:2845 |
| Check | Pressed | Small | true | false | 26184:2851 |
| Default | Default | Large | false | true | 26184:2857 |
| Default | Default | Medium | false | true | 26184:2863 |
| Default | Default | Small | false | true | 26184:2869 |
| Default | Pressed | Large | false | true | 26184:2875 |
| Default | Pressed | Medium | false | true | 26184:2881 |
| Default | Pressed | Small | false | true | 26184:2887 |
| Default | Default | Large | true | true | 26184:2893 |
| Default | Default | Medium | true | true | 26184:2899 |
| Default | Default | Small | true | true | 26184:2905 |
| Default | Pressed | Large | true | true | 26184:2911 |
| Default | Pressed | Medium | true | true | 26184:2917 |
| Default | Pressed | Small | true | true | 26184:2923 |
Style × State × Size × isSelected × isError, mirroring the Radio Button atom exactly (same props, same order, same coverage).
Restructuredsize no longer encodes state — the compound "default - error" / "large - error" values are replaced by orthogonal Size × State × isError props. Native enum mapping is clean.
ResolvedSize=Large nests the 24 × 24 atom instead of hardcoding the 16 × 16 small radio into every variant.
ResolvedisSelected, Pressed, Disabled, and isError, including Disabled + isSelected=false so disabled groups render their unselected rows.
Resolved_space_12 spacer removed — the 12px gap is now real auto-layout spacing. The annotation instance (#0500FF fill wrapping a Roboto "12" text node) no longer ships inside the component, and layout maps to native padding rather than a spacer view.
Resolvedicon-ofsset → icon-offset across all 39 variants.
Resolved26184:2839 / 2845 / 2851 were labelled isError=true while painted pressed-navy, leaving Check + Pressed + isError=false non-existent. Renamed to isError=false; the wrapper now matches the atom.
ResolvedDisabled + isError=true not covered — same gap as the atom. Likely intentional, but undocumented. Confirm and record.
Opensize prop encodes state — "default - error" and "large - error" mix size + state. Should split. Open