East Blue
KeepNeeds Refinement
Radio Button Component link

A circular radio control for single-select groups. 39 variants across Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError.

Keep — structurally clean
All four DS Health traits pass and nothing is open. Five independent axes — Style, State, Size, isSelected, isError — with a vector ring and dot at all three sizes, and a real Pressed state.
In Context

How the radio button appears in real product screens — Yes / No answers on a Fill Information form, and a choice of when a recurring payment ends.

Radio Button component shown on two GCash screens: Yes and No radio pairs for the PEP and beneficial ownership questions on Fill Information, and End options On a specific date, After a number of payments and No end date on a recurring payment
Live Preview
Properties
selected
size
style
DS Health
Reusable
Pass
Works across forms, surveys, preference pickers, and single-select list rows. Three sizes (24 / 20 / 16) cover the Large, Medium, and Small text scales, and the Check style extends it to iOS-style picker rows without a second component.
Self-contained
Pass
Every size is built from layered vectors — 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.
Consistent
Pass
Five orthogonal props — 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.
Composable
Pass
Nests inside Radio Button – With Label, which mirrors this schema exactly — same props, same order, same 39 variants. Layers are semantic (container / circle / ring) and the Check style is a vector icon instance rather than a drawn shape.
Behavior
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.
Issues
  • v2.0: Variant matrix restructured into orthogonal axes — Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError. selected no longer conflates selection with modifier states, and no property is conditional on another. (C2)
  • v2.0: Pressed folded into State, error split into an orthogonal isError boolean — removes the illegal Disabled + Pressed combination 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-rendered imgContainer raster is gone, so token changes now propagate to every size. (C3)
  • v2.0: Misleading .base/checkbox frame renamed — layers are now container / circle / ring. (C6)
  • v2.0: Pressed state added — State=Pressed covers 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=false added 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: Disabled deliberately excludes isError — 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=Check confirmed 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)
Recommendations
  • Split properties into orthogonal axes. v2.0: Applied — and taken further than proposed. Style × State × Size × isSelected × isError, with pressed folded into State and 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 + ring vector ellipses. Tokens flow to all three sizes. Asset
  • Rename the internal frame. v2.0: Applied — .base/checkbox is gone; layers are container / circle / ring. Rename
  • Add pressed states. v2.0: Applied — State=Pressed maps 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 + isError as 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

Component Playground

Layout

Typography

Colors

Installation Planned API

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.*
Property Mapping
Figma PropertySwiftUICompose
Style — Check, Default .ebRadioStyle(.check / .default) style = EBRadioStyle.Check / Default
State — Default, Pressed, Disabled .disabled(true) for Disabled; Pressed is the press gesture, not a parameter enabled = false for Disabled; Pressed comes from interactionSource
Size — Small, Medium, Large .controlSize(.small / .regular / .large) size = EBRadioSize.Small / Medium / Large
isSelected — false, true isSelected: Bool selected: Boolean
isError — false, true .ebInvalid(true) isError = true
— no Figma property (selection callback) onTap: () -> Void onClick: () -> Unit
SwiftUI
ios/Components/Radio/EBRadioButton.swift
Jetpack Compose
android/components/radio/EBRadioButton.kt
Usage Snippets Planned API
Default
// Unselected — the empty ring
EBRadioButton(isSelected: $selected)
    .controlSize(.large)

// One option in a single-select group
ForEach(options) { option in
    EBRadioButton(isSelected: .constant(option == choice))
        .onTapGesture { choice = option }
}
// Unselected — the empty ring
EBRadioButton(
    selected = selected,
    onClick = { selected = true },
    size = EBRadioSize.Large
)

// One option in a single-select group
options.forEach { option ->
    EBRadioButton(
        selected = option == choice,
        onClick = { choice = option }
    )
}
Check
// Checkmark disc — settings and picker rows.
// Built only as selected; there is no unselected Check.
EBRadioButton(isSelected: $selected)
    .ebRadioStyle(.check)
    .controlSize(.large)
// Checkmark disc — settings and picker rows.
// Built only as selected; there is no unselected Check.
EBRadioButton(
    selected = true,
    onClick = { },
    style = EBRadioStyle.Check,
    size = EBRadioSize.Large
)
Validation and disabled
// Failed validation the user can still act on
EBRadioButton(isSelected: $selected)
    .ebInvalid(true)

// Locked. Never pair .disabled(true) with .ebInvalid(true) —
// Figma ships no Disabled + isError variant.
EBRadioButton(isSelected: .constant(true))
    .disabled(true)
// Failed validation the user can still act on
EBRadioButton(
    selected = selected,
    onClick = { selected = !selected },
    isError = true
)

// Locked. Never pair enabled = false with isError = true —
// Figma ships no Disabled + isError variant.
EBRadioButton(
    selected = true,
    onClick = { },
    enabled = false
)
Accessibility
RequirementiOSAndroid
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 The control is 24 / 20 / 16pt — every size needs a 44 × 44pt hit area around it The control is 24 / 20 / 16dp — every size needs a 48 × 48dp hit area around it
Error announcement Pair with a label and announce the error message after the label Use semantics { error(...) }
Disabled contrast Unselected + Disabled is the default ring at 40% opacity — do not rely on it alone; dim the paired label too Same 40% ring; set enabled = false on the whole selectable row so the label dims with it
Usage Guidelines

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

Do

Reach for <code>Style=Check</code> only when the row is already selected-or-not; it exists in nine variants, all <code>isSelected=true</code>.

Don't

Don't try to render an unselected or errored Check — Figma builds neither. Fall back to <code>Style=Default</code> for those states.

Criteria Scorecard
ID Criterion Status Notes
C1 Layer Structure & Naming Ready Semantic throughout — container → circle + ring; the misleading .base/checkbox frame is gone (v2.0). The four error-selected variants carry a stroke on container as well as on ring — harmless, but one of them is redundant.
C2 Variant & Property Naming Ready Five orthogonal axes, no conditional properties. Booleans use the is prefix with lowercase true/false values — the reference implementation of the C2 boolean convention.
C3 Token Coverage Needs Refinement Every fill is consistent across all three sizes, so nothing is size-specific or raster-baked. Two gaps: Disabled + isSelected=false paints the ordinary #D7E0EF ring at opacity 0.4 rather than binding a disabled token — while Disabled + isSelected=true does use a distinct #9BC5FD; and variable bindings themselves cannot be read with the Talk To Figma plugin, so the token paths on the Style tab are attested, not verified.
C4 Native Mappability Ready Every axis maps to a native parameter or modifier — Toggle with a radio style on iOS, Modifier.selectable(role = Role.RadioButton) on Android. No web-only patterns.
C5 Interaction State Coverage Ready Default, Pressed and Disabled ship at all three sizes for both Style values. Disabled + isError is absent on purpose — recorded, not a gap. Focused is N/A on mobile.
C6 Asset & Icon Quality Needs Refinement All vector — the pre-rendered large radio is gone and the Check glyph is an icon instance. One gap: the glyph does not scale between sizes. Large and Medium both draw a 16 × 16 icon with a 3px stroke, so at Medium it fills proportionally more of the disc; only Small steps down to 12 × 12 / 2.25px.
Variants Inventory (39 total)

Style (2) × State (3) × Size (3) × isSelected (2) × isError (2) = 72 theoretical · 39 ship. Check is built only as isSelected=true, isError=false, which accounts for 27 of the 33 absent slots; the remaining 6 are Disabled paired with isError=true, omitted on purpose — a locked control gives the user no way to resolve the error. Summary groups all three sizes per row; node IDs are in the full breakdown.

StyleStateisSelectedisErrorCountFill
DefaultDefaultfalsefalse3#D7E0EF ring
DefaultDefaulttruefalse3#005CE5 ring + dot
DefaultPressedfalsefalse3#ADBDDC ring
DefaultPressedtruefalse3#2340A9 ring + dot
DefaultDisabledfalsefalse3#D7E0EF @ 40% ring
DefaultDisabledtruefalse3#9BC5FD ring + dot
DefaultDefaultfalsetrue3#D61B2C ring
DefaultDefaulttruetrue3#D61B2C ring + dot
DefaultPressedfalsetrue3#B50707 ring
DefaultPressedtruetrue3#B50707 ring + dot
CheckDefaulttruefalse3#005CE5 disc + white check
CheckPressedtruefalse3#2340A9 disc + white check
CheckDisabledtruefalse3#9BC5FD disc + white check
View full Style × State × Size breakdown (39 rows)
StyleStateSizeisSelectedisErrorNodeFill
DefaultDisabledLargefalsefalse26203:601#D7E0EF @ 40% ring
DefaultDisabledMediumfalsefalse26203:598#D7E0EF @ 40% ring
DefaultDisabledSmallfalsefalse26203:604#D7E0EF @ 40% ring
DefaultDefaultLargefalsefalse26184:2589#D7E0EF ring
DefaultDefaultMediumfalsefalse26184:2592#D7E0EF ring
DefaultDefaultSmallfalsefalse26184:2595#D7E0EF ring
DefaultPressedLargefalsefalse26184:2598#ADBDDC ring
DefaultPressedMediumfalsefalse26184:2601#ADBDDC ring
DefaultPressedSmallfalsefalse26184:2604#ADBDDC ring
DefaultDisabledLargetruefalse26184:2607#9BC5FD ring + dot
DefaultDisabledMediumtruefalse26184:2611#9BC5FD ring + dot
DefaultDisabledSmalltruefalse26184:2615#9BC5FD ring + dot
CheckDisabledLargetruefalse26184:2619#9BC5FD disc + white check
CheckDisabledMediumtruefalse26184:2622#9BC5FD disc + white check
CheckDisabledSmalltruefalse26184:2625#9BC5FD disc + white check
DefaultDefaultLargetruefalse26184:2628#005CE5 ring + dot
DefaultDefaultMediumtruefalse26184:2632#005CE5 ring + dot
DefaultDefaultSmalltruefalse26184:2636#005CE5 ring + dot
DefaultPressedLargetruefalse26184:2640#2340A9 ring + dot
DefaultPressedMediumtruefalse26184:2644#2340A9 ring + dot
DefaultPressedSmalltruefalse26184:2648#2340A9 ring + dot
CheckDefaultLargetruefalse26184:2652#005CE5 disc + white check
CheckDefaultMediumtruefalse26184:2655#005CE5 disc + white check
CheckDefaultSmalltruefalse26184:2658#005CE5 disc + white check
CheckPressedLargetruefalse26184:2661#2340A9 disc + white check
CheckPressedMediumtruefalse26184:2664#2340A9 disc + white check
CheckPressedSmalltruefalse26184:2667#2340A9 disc + white check
DefaultDefaultLargefalsetrue26184:2670#D61B2C ring
DefaultDefaultMediumfalsetrue26184:2673#D61B2C ring
DefaultDefaultSmallfalsetrue26184:2676#D61B2C ring
DefaultPressedLargefalsetrue26184:2679#B50707 ring
DefaultPressedMediumfalsetrue26184:2682#B50707 ring
DefaultPressedSmallfalsetrue26184:2685#B50707 ring
DefaultDefaultLargetruetrue26184:2688#D61B2C ring + dot
DefaultDefaultMediumtruetrue26184:2692#D61B2C ring + dot
DefaultDefaultSmalltruetrue26184:2696#D61B2C ring + dot
DefaultPressedLargetruetrue26184:2700#B50707 ring + dot
DefaultPressedMediumtruetrue26184:2704#B50707 ring + dot
DefaultPressedSmalltruetrue26184:2708#B50707 ring + dot
2.0.1 — September 2026Patch
Style + Code tabs rebuilt against the live component · node 26184:2588
Style tab rebuilt off the live component. The documented control was not the one Figma ships: it described a selected enum (unselected/selected/disabled/error), a style axis of default/filled/checkmark, and two sizes. Replaced with the real five axes read from node 26184:2588.
Docs
Four colour values were wrong and one state was missing entirely. The selected inner dot was documented white — it is #005CE5, the same blue as the ring, and white appears only as the Check glyph. Disabled-selected was #C2CFE5, actually #9BC5FD. The whole Pressed state (#ADBDDC · #2340A9 · #B50707) was undocumented, as was Size=Medium.
C3 Resolved
Ring stroke corrected and made size-aware. The Layout section said 1.5px while the colours table said 2px; both were wrong. Read off the SVG exports: 3px at Large, 2.5px at Medium, 2px at Small, with the inner dot at exactly half the box.
Docs
Demo panel now mirrors the Figma property panel. One spec card carrying all five properties — Style · State · Size · isSelected · isError — in panel order, with Figma’s own value order and lowercase boolean values. Combinations Figma does not build are disabled rather than offered: reachable 39 equals built 39.
Docs
Second colours table was dead markup. colorsTables[i] pairs positionally with specCards[i], so with one card the second table never rendered. Merged into a single Colors by Style and State table.
Docs
Code tab scorecard contradicted the v2.0 record. C1 still cited the .base/checkbox frame, C2 the mixed selected axis, C3 the raster large radio, C5 the missing pressed state, C6 the pre-rendered image — all five resolved in v2.0 and recorded as such on the Overview tab. Rescored C1, C2, C4 and C5 Ready.
Docs
Property Mapping rewritten against the current schema. It still mapped the retired axes — selected=unselected/selected, size=small/large, style=filled/checkmark marked "retire". Now one prose row per property with all its values, plus the selection callback.
Docs
Installation coordinates corrected. Gradle artifact was com.eastblue.ds:radio at version 1.0.0; the slug is radio-button and the version tracks this entry. Import block added — import EastBlueDS and com.eastblue.ds.radio.*, the package derived from the Radio family.
Docs
Variants Inventory gained a full breakdown. 39 rows carrying every node ID and its verified fill, behind the summary. The absent-slot arithmetic is now stated: 33 of 72 theoretical, being 27 Check (built only as isSelected=true, isError=false) and 6 Disabled + isError.
Docs
Correction to v2.0.0. That entry recorded Disabled + isError=true as "9 slots absent". It is 6 — two combinations (isSelected false and true) across three sizes. The v2.0.0 entry is left as written; this row is the correction.
C5 Resolved
Disabled uses two different mechanisms. Unselected-disabled wraps the ordinary #D7E0EF ring in opacity 0.4; selected-disabled uses a distinct #9BC5FD at full opacity. One native modifier cannot express both. Logged against C3, not yet filed as an Overview issue.
C3 Open
Check glyph does not scale between Large and Medium. Both draw a 16 × 16 icon at stroke-width 3 inside a 24px and a 20px disc; only Small steps down to 12 × 12 at 2.25. Logged against C6.
C6 Open
Token bindings unread. The Talk To Figma plugin returns no variable bindings, so 8 of the 15 colour rows carry — and the rest are attested from the previous record where the hex still agreed. Needs a Dev Mode pass.
C3 Open
2.0.0 — July 2026Major
Rebuilt · node 26184:2588
Component rebuilt on a new node — 39 variants across Style (Default/Check) × State (Default/Pressed/Disabled) × Size (Large/Medium/Small) × isSelected × isError, replacing the old sparse selected × size × style matrix. Restructured
Rebuild
Properties split into orthogonal axes — pressed folded into State, error split into an orthogonal isError boolean. Removes the illegal Disabled + Pressed combination and aligns with Button v4.0 / Select Item. Resolved
C2 · C4 Resolved
Large radio rebuilt as vector layers — container → circle + ring ellipses on #005CE5. The pre-rendered imgContainer raster is gone; tokens now propagate to all three sizes. Resolved
C3 Resolved
Layer naming fixed + checkmark promoted to an icon — .base/checkbox replaced by container / circle / ring; the Check style now nests a vector Checkmark icon instance. Resolved
C6 Resolved
Pressed state + disabled-unselected coverage added — State=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. Resolved
C5 Resolved
Boolean variant values lowercased — True/False → true/false, matching the C2 convention. Radio Button is now the reference for boolean vocabulary in the system. Resolved
C2 Resolved
Check + Pressed mislabel corrected — three Check variants were relabelled isError=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. Resolved
C5 Resolved
Disabled + isError=true not covered — 9 slots absent. Likely intentional, but undocumented. Confirm and record. Open
C5 Open
Code Connect mappings — now unblocked by the rebuild; SwiftUI / Compose mappings not yet registered. Open
C7 Open
1.0.0 — April 2026Major
Initial Assessment · node 18482:35698
Component assessed — 11 variants across sparse selected × size × style matrix. Documented
Initial
Sparse matrix, mixed paradigms — selected mixes selection with modifier states; style is only meaningful when selected. Open
C2 Open
Large radio is raster-baked — tokens don't flow to the large size. Open
C3 Open
Internal frame named .base/checkbox + checkmark style overlaps with Checkbox. Open
C6 Open
No pressed/focused states. Open
C5 Open
Code Connect mappings — Not registered. Open
C7 Open