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")
        }
    }
}
ComposableVisual weightTypical use in RecipeBox
ButtonFilled, solid background — highest emphasis"Save Recipe" on the add/edit screen
OutlinedButtonOutline only, transparent fill — medium emphasis"Discard Changes" — a real action, but not the primary one
TextButtonNo 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.

Next up: TextField & User Input covers the composable behind that "Save Recipe" title field itself — and why Compose needs you to wire up its value manually.