RadioButton components provide single-choice selection within a group, ensuring only one option can be selected at a time.
The RadioButton component allows users to select exactly one option from a set of mutually exclusive choices. It follows established radio button patterns with full accessibility support and flexible grouping.
@Composable
fun RadioButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier(),
enabled: Boolean = true
)
Parameters:
selected: Whether this radio button is currently selected within its grouponClick: Callback invoked when this radio button is clickedmodifier: Modifier for styling and layoutenabled: Whether the radio button can be interacted with (default: true)@Composable
fun RadioButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier(),
enabled: Boolean = true,
label: String? = null,
labelPosition: LabelPosition = LabelPosition.END,
radioButtonStyle: Modifier = Modifier()
)
Parameters:
selected: Whether this radio button is currently selectedonClick: Callback invoked when clickedmodifier: Modifier applied to the entire componentenabled: Whether the radio button is interactivelabel: Optional text labellabelPosition: Where to position the label (START or END)radioButtonStyle: Modifier applied specifically to the radio input@Composable
fun RadioButtonWithLabel(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier(),
enabled: Boolean = true,
label: @Composable () -> Unit
)
Parameters:
selected: Whether this radio button is currently selectedonClick: Callback invoked when clickedmodifier: Modifier applied to the container rowenabled: Whether the radio button is interactivelabel: Composable lambda for custom label contentenum class LabelPosition {
START, // Label appears before the radio button
END // Label appears after the radio button
}
@Composable
fun BasicRadioGroupExample() {
val options = listOf("Option 1", "Option 2", "Option 3")
var selectedOption by remember { mutableStateOf(options[0]) }
Column(modifier = Modifier().gap(Spacing.SM)) {
Text("Choose an option:", style = Typography.H6)
options.forEach { option ->
RadioButton(
selected = selectedOption == option,
onClick = { selectedOption = option },
label = option,
modifier = Modifier().padding(vertical = Spacing.XS)
)
}
Text("Selected: $selectedOption")
}
}
data class ColorOption(val name: String, val value: String)
@Composable
fun ColorRadioGroupExample() {
val colorOptions = listOf(
ColorOption("Red", "#FF0000"),
ColorOption("Green", "#00FF00"),
ColorOption("Blue", "#0000FF")
)
var selectedColor by remember { mutableStateOf(colorOptions[0]) }
Column(modifier = Modifier().gap(Spacing.SM)) {
Text("Choose a color:", style = Typography.H6)
colorOptions.forEach { color ->
RadioButton(
selected = selectedColor == color,
onClick = { selectedColor = color },
label = color.name,
modifier = Modifier()
.padding(vertical = Spacing.XS)
.backgroundColor(Color.parse(color.value).copy(alpha = 0.1f))
.borderRadius(BorderRadius.SM)
.padding(Spacing.SM)
)
}
Box(
modifier = Modifier()
.width(50.px)
.height(50.px)
.backgroundColor(Color.parse(selectedColor.value))
.borderRadius(BorderRadius.MD)
.marginTop(Spacing.MD)
) {}
}
}
@Composable
fun LabelPositionExample() {
var selectedPosition by remember { mutableStateOf("start") }
Column(modifier = Modifier().gap(Spacing.LG)) {
Text("Label Position:", style = Typography.H6)
// Label at start
RadioButton(
selected = selectedPosition == "start",
onClick = { selectedPosition = "start" },
label = "Label at start",
labelPosition = LabelPosition.START
)
// Label at end (default)
RadioButton(
selected = selectedPosition == "end",
onClick = { selectedPosition = "end" },
label = "Label at end",
labelPosition = LabelPosition.END
)
}
}
@Composable
fun CustomLabelExample() {
val paymentMethods = listOf("Credit Card", "PayPal", "Bank Transfer")
var selectedMethod by remember { mutableStateOf(paymentMethods[0]) }
Column(modifier = Modifier().gap(Spacing.MD)) {
Text("Payment Method:", style = Typography.H6)
paymentMethods.forEach { method ->
RadioButtonWithLabel(
selected = selectedMethod == method,
onClick = { selectedMethod = method },
modifier = Modifier()
.padding(Spacing.SM)
.border(
Border.solid(
1.px,
if (selectedMethod == method) Colors.Primary.MAIN else Colors.Gray.LIGHT
)
)
.borderRadius(BorderRadius.MD)
) {
Row(
modifier = Modifier()
.alignItems(AlignItems.Center)
.gap(Spacing.SM)
) {
Icon(
name = when (method) {
"Credit Card" -> "credit_card"
"PayPal" -> "payment"
else -> "account_balance"
}
)
Column {
Text(method, style = Typography.BODY1.copy(fontWeight = FontWeight.MEDIUM))
Text(
when (method) {
"Credit Card" -> "Visa, MasterCard, Amex"
"PayPal" -> "Pay with your PayPal account"
else -> "Direct bank transfer"
},
style = Typography.CAPTION
)
}
}
}
}
}
}
@Composable
fun RadioButtonFormExample() {
var selectedSize by remember { mutableStateOf("") }
var selectedColor by remember { mutableStateOf("") }
Form(
onSubmit = {
println("Selected: Size=$selectedSize, Color=$selectedColor")
}
) {
FormField(label = "Size") {
Column(modifier = Modifier().gap(Spacing.XS)) {
listOf("Small", "Medium", "Large").forEach { size ->
RadioButton(
selected = selectedSize == size,
onClick = { selectedSize = size },
label = size
)
}
}
}
FormField(label = "Color") {
Column(modifier = Modifier().gap(Spacing.XS)) {
listOf("Red", "Blue", "Green").forEach { color ->
RadioButton(
selected = selectedColor == color,
onClick = { selectedColor = color },
label = color
)
}
}
}
Button(
text = "Add to Cart",
type = ButtonType.SUBMIT,
enabled = selectedSize.isNotEmpty() && selectedColor.isNotEmpty()
)
}
}
@Composable
fun DisabledRadioExample() {
val options = listOf(
"Available" to true,
"Limited Stock" to true,
"Out of Stock" to false
)
var selectedOption by remember { mutableStateOf("Available") }
Column(modifier = Modifier().gap(Spacing.SM)) {
Text("Product Options:", style = Typography.H6)
options.forEach { (option, enabled) ->
RadioButton(
selected = selectedOption == option,
onClick = { if (enabled) selectedOption = option },
enabled = enabled,
label = option,
modifier = Modifier().padding(vertical = Spacing.XS)
)
}
}
}
@Composable
fun ValidatedRadioGroupExample() {
val options = listOf("Option A", "Option B", "Option C")
var selectedOption by remember { mutableStateOf<String?>(null) }
var showError by remember { mutableStateOf(false) }
Column(modifier = Modifier().gap(Spacing.SM)) {
Text("Required Selection *", style = Typography.H6)
options.forEach { option ->
RadioButton(
selected = selectedOption == option,
onClick = {
selectedOption = option
showError = false
},
label = option,
modifier = Modifier().padding(vertical = Spacing.XS)
)
}
if (showError) {
Text(
"Please select an option",
style = Typography.CAPTION.copy(color = Colors.Error.MAIN),
modifier = Modifier().padding(top = Spacing.XS)
)
}
Button(
text = "Continue",
onClick = {
if (selectedOption == null) {
showError = true
} else {
println("Selected: $selectedOption")
}
}
)
}
}
@Composable
fun StyledRadioGroupExample() {
val themes = listOf("Light", "Dark", "Auto")
var selectedTheme by remember { mutableStateOf("Light") }
Column(modifier = Modifier().gap(Spacing.MD)) {
Text("Theme Selection:", style = Typography.H6)
themes.forEach { theme ->
RadioButton(
selected = selectedTheme == theme,
onClick = { selectedTheme = theme },
label = theme,
modifier = Modifier()
.width(Width.FULL)
.padding(Spacing.MD)
.backgroundColor(
if (selectedTheme == theme) {
Colors.Primary.LIGHT
} else {
Colors.Gray.LIGHT
}
)
.borderRadius(BorderRadius.LG)
.border(
Border.solid(
2.px,
if (selectedTheme == theme) {
Colors.Primary.MAIN
} else {
Colors.Transparent
}
)
),
radioButtonStyle = Modifier()
.accentColor(Colors.Primary.MAIN)
)
}
}
}
The RadioButton component automatically includes:
role="radio" for screen readersaria-checked state managementradiogroup role for containersaria-labelledby for group labels@Composable
fun AccessibleRadioGroupExample() {
val sizes = listOf("Small", "Medium", "Large")
var selectedSize by remember { mutableStateOf("Medium") }
// Accessible radio group
Box(
modifier = Modifier()
.accessibilityRole("radiogroup")
.accessibilityLabel("Size selection")
) {
Column(modifier = Modifier().gap(Spacing.SM)) {
Text(
"Select Size:",
style = Typography.H6,
modifier = Modifier()
.accessibilityRole("heading")
.accessibilityLevel(3)
)
sizes.forEach { size ->
RadioButton(
selected = selectedSize == size,
onClick = { selectedSize = size },
label = size,
modifier = Modifier()
.accessibilityLabel("Size $size")
.accessibilityHint(
if (selectedSize == size) "Currently selected" else "Double tap to select"
)
)
}
}
}
}
@Composable
fun ExternalStateExample() {
// State managed by parent component
var selectedValue by remember { mutableStateOf("option1") }
fun handleSelection(value: String) {
selectedValue = value
// Additional logic like analytics, validation, etc.
println("Option selected: $value")
}
Column {
listOf("option1", "option2", "option3").forEach { option ->
RadioButton(
selected = selectedValue == option,
onClick = { handleSelection(option) },
label = option.capitalize()
)
}
// External control
Button(
text = "Reset to Option 1",
onClick = { handleSelection("option1") }
)
}
}
data class Product(val id: String, val name: String, val price: Double)
@Composable
fun ProductSelectionExample() {
val products = listOf(
Product("1", "Basic Plan", 9.99),
Product("2", "Pro Plan", 19.99),
Product("3", "Enterprise Plan", 39.99)
)
var selectedProduct by remember { mutableStateOf<Product?>(null) }
Column(modifier = Modifier().gap(Spacing.MD)) {
Text("Choose a plan:", style = Typography.H6)
products.forEach { product ->
RadioButtonWithLabel(
selected = selectedProduct == product,
onClick = { selectedProduct = product },
modifier = Modifier()
.width(Width.FULL)
.padding(Spacing.MD)
.border(
Border.solid(
1.px,
if (selectedProduct == product) Colors.Primary.MAIN else Colors.Gray.LIGHT
)
)
.borderRadius(BorderRadius.MD)
) {
Row(
modifier = Modifier()
.width(Width.FULL)
.justifyContent(JustifyContent.SpaceBetween)
.alignItems(AlignItems.Center)
) {
Text(product.name, style = Typography.BODY1)
Text(
"$${product.price}/month",
style = Typography.BODY2.copy(fontWeight = FontWeight.BOLD)
)
}
}
}
selectedProduct?.let { product ->
Text(
"Selected: ${product.name} - $${product.price}/month",
style = Typography.BODY2,
modifier = Modifier()
.padding(top = Spacing.MD)
.backgroundColor(Colors.Success.LIGHT)
.padding(Spacing.SM)
.borderRadius(BorderRadius.SM)
)
}
}
}
<input type="radio"> elementname attributeremember for stable callback references@Composable
fun OptimizedRadioGroupExample() {
val options = remember { generateLargeOptionsList() }
var selectedOption by remember { mutableStateOf<String?>(null) }
// Memoize the click handler to prevent recreation
val handleOptionClick = remember {
{ option: String ->
selectedOption = option
// Additional logic
}
}
// For very large lists, consider LazyColumn
if (options.size > 20) {
LazyColumn {
items(options) { option ->
RadioButton(
selected = selectedOption == option,
onClick = { handleOptionClick(option) },
label = option
)
}
}
} else {
Column {
options.forEach { option ->
RadioButton(
selected = selectedOption == option,
onClick = { handleOptionClick(option) },
label = option
)
}
}
}
}
class RadioButtonTest {
@Test
fun `radio button group maintains single selection`() {
val options = listOf("A", "B", "C")
var selectedOption = "A"
composeTestRule.setContent {
options.forEach { option ->
RadioButton(
selected = selectedOption == option,
onClick = { selectedOption = option },
label = option
)
}
}
// Select option B
composeTestRule.onNodeWithText("B").performClick()
assertEquals("B", selectedOption)
// Select option C
composeTestRule.onNodeWithText("C").performClick()
assertEquals("C", selectedOption)
}
@Test
fun `disabled radio button does not respond to clicks`() {
var selectedOption = "A"
composeTestRule.setContent {
RadioButton(
selected = false,
onClick = { selectedOption = "B" },
enabled = false,
label = "Disabled option"
)
}
composeTestRule.onNodeWithText("Disabled option").performClick()
assertEquals("A", selectedOption) // Should not change
}
}
@Test
fun `radio group integrates with form submission`() {
var submittedValue = ""
composeTestRule.setContent {
var selectedValue by remember { mutableStateOf("") }
Form(onSubmit = { submittedValue = selectedValue }) {
listOf("Option 1", "Option 2").forEach { option ->
RadioButton(
selected = selectedValue == option,
onClick = { selectedValue = option },
label = option
)
}
Button(text = "Submit", type = ButtonType.SUBMIT)
}
}
// Select an option and submit
composeTestRule.onNodeWithText("Option 2").performClick()
composeTestRule.onNodeWithText("Submit").performClick()
assertEquals("Option 2", submittedValue)
}
<!-- Before: HTML -->
<input type="radio" id="small" name="size" value="small">
<label for="small">Small</label>
<input type="radio" id="large" name="size" value="large">
<label for="large">Large</label>
// After: Summon
@Composable
fun SizeSelection() {
var selectedSize by remember { mutableStateOf("small") }
Column {
RadioButton(
selected = selectedSize == "small",
onClick = { selectedSize = "small" },
label = "Small"
)
RadioButton(
selected = selectedSize == "large",
onClick = { selectedSize = "large" },
label = "Large"
)
}
}
// React example
const [selected, setSelected] = useState('option1');
{options.map(option => (
<input
key={option}
type="radio"
checked={selected === option}
onChange={() => setSelected(option)}
/>
))}
// Summon equivalent
var selected by remember { mutableStateOf("option1") }
options.forEach { option ->
RadioButton(
selected = selected == option,
onClick = { selected = option },
label = option
)
}