Buttons & Clickable
Tappable UI in Compose comes in two flavors: the dedicated Button family for actions
that should visually read as buttons, and the generic Modifier.clickable {} for making
any composable — a whole card, an icon, a row — respond to a tap even though it isn't a
button at all.
Material 3 Button Variants
Material 3 gives you three visual weights of button, and picking the right one communicates hierarchy to the user — the primary action on a screen shouldn't look the same as a minor, secondary one:
@Composable
fun AddRecipeActions(onSave: () -> Unit, onDiscard: () -> Unit, onCancel: () -> Unit) {
Column {
Button(onClick = onSave) {
Text("Save Recipe")
}
OutlinedButton(onClick = onDiscard) {
Text("Discard Changes")
}
TextButton(onClick = onCancel) {
Text("Cancel")
}
}
}
| Composable | Visual weight | Typical use in RecipeBox |
|---|---|---|
Button | Filled, solid background — highest emphasis | "Save Recipe" on the add/edit screen |
OutlinedButton | Outline only, transparent fill — medium emphasis | "Discard Changes" — a real action, but not the primary one |
TextButton | No background or outline, text only — lowest emphasis | "Cancel" inside a dialog, dismissive actions |
Modifier.clickable: Making Anything Tappable
A whole recipe card in the list should open the recipe detail screen when tapped, anywhere the user
touches it — that's not a job for a Button (which forces a specific visual style).
Modifier.clickable {} attaches tap handling, the ripple feedback, and accessibility
click semantics to any composable at all:
@Composable
fun RecipeCard(
title: String,
prepMinutes: Int,
onCardClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onCardClick)
.padding(16.dp)
) {
Column {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = "$prepMinutes min", style = MaterialTheme.typography.bodySmall)
}
}
}
Every pixel of that Row — the title, the prep time, the empty space around them — now
triggers onCardClick when tapped, and Compose automatically draws the standard Material
ripple animation from the tap point. Notice clickable is placed before
padding in the chain, matching the Modifier order lesson: this makes the entire padded
area tappable, not just the tight bounds around the text.
Enabling and Disabling Based on State
Both Button and clickable accept an enabled parameter. A
disabled Button automatically dims itself and stops responding to taps — useful for
RecipeBox's "Save Recipe" button, which should stay disabled until the required title field is
actually filled in:
@Composable
fun SaveRecipeButton(title: String, onSave: () -> Unit) {
Button(
onClick = onSave,
enabled = title.isNotBlank()
) {
Text("Save Recipe")
}
}
clickable supports the same idea, though it has no built-in dimmed visual —
you'd combine enabled = false with your own visual treatment (like a lower
alpha) if you disable a whole card rather than a Button:
@Composable
fun RecipeCard(
title: String,
isSyncing: Boolean,
onCardClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.alpha(if (isSyncing) 0.5f else 1f)
.clickable(enabled = !isSyncing, onClick = onCardClick)
.padding(16.dp)
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
}
}
While a recipe is mid-sync with the server, isSyncing disables the tap and fades the
card to half opacity, giving the user a clear visual cue that this row is temporarily unavailable.