Five buttons, one hierarchy, and the rule that decides which

Material 3 ships five button styles plus the FAB. They form an emphasis hierarchy, and the selection rule is about how many actions compete on a screen rather than about how each one looks.

5 min read
androidcomposekotlinmaterial3

Day 36 — Five buttons, one hierarchy, and the rule that decides which

Day 36 of 100. Buttons look like the simplest component in the library, and the choice between five of them is a design decision the API is quietly asking you to make.

The symptom

A dialog where everything is important:

Row {
    Button(onClick = ::delete) { Text("Delete") }
    Button(onClick = ::archive) { Text("Archive") }
    Button(onClick = ::cancel) { Text("Cancel") }
}

Three filled buttons, all shouting. The user has to read all three to find the one they want, and "Cancel" carries the same visual weight as "Delete" — which is the opposite of the intent, since one is reversible and the other isn't.

The screen looks busy, and nobody can say exactly why.

Why the obvious fix fails

The obvious fix is to make the dangerous one red:

Button(
    onClick = ::delete,
    colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
) { Text("Delete") }

Now the most destructive action is also the most visually prominent, which draws the eye toward the thing you least want mis-tapped. Colour has been used to signal danger while the fill continues to signal primary action, and those two signals now disagree.

There's also a theming problem: a hardcoded Color.Red ignores dark mode and the error colour the theme already defines.

Five button styles form an emphasis hierarchy — pick by how many actions compete, not by looks

The actual mechanism

The five styles are an emphasis scale, high to low:

Button(onClick = {}) { Text("Filled") }               // highest — the primary action
FilledTonalButton(onClick = {}) { Text("Tonal") }     // high, softer
OutlinedButton(onClick = {}) { Text("Outlined") }     // medium — secondary
ElevatedButton(onClick = {}) { Text("Elevated") }     // medium, for busy backgrounds
TextButton(onClick = {}) { Text("Text") }             // lowest — tertiary, dismissive

The selection rule is about competition, not appearance:

One primary action per screen region gets Button. If two things are filled, neither is primary.

The secondary action gets OutlinedButton or FilledTonalButton. Tonal when the two actions are near-peers ("Save" / "Save as draft"); outlined when the second is clearly subordinate.

Dismissive actions get TextButton. Cancel, Later, Skip. They must be reachable and should never compete.

ElevatedButton is situational — it exists so a button stays legible on a colourful or image background where an outline would disappear.

The dialog becomes:

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    TextButton(onClick = ::cancel) { Text("Cancel") }
    OutlinedButton(onClick = ::archive) { Text("Archive") }
    Button(onClick = ::delete) { Text("Delete") }
}

One filled button, and the eye lands on the action the user came for.

Destructive actions

Colour comes from the theme, not from a literal:

Button(
    onClick = ::delete,
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.error,
        contentColor = MaterialTheme.colorScheme.onError,
    ),
) { Text("Delete") }

error/onError are a contrast-checked pair that adapt to dark mode. Color.Red with white text is a contrast failure in some themes and unreadable in others.

Worth noting the convention: a destructive action is usually not the filled button in a confirmation dialog. Material's own guidance puts the safe choice as primary and the destructive one as a text or outlined button, so the default tap target is the harmless one.

Icon buttons and the touch target

IconButton is a separate component because it carries a required behaviour:

IconButton(onClick = ::share) {
    Icon(Icons.Default.Share, contentDescription = "Share")
}

It applies a 48dp minimum touch target regardless of the icon's visual size — the defaultMinSize from Day 21, doing accessibility work. Building the same thing with Icon(Modifier.clickable { }) produces a 24dp target that fails accessibility scanning and is genuinely hard to hit.

The contentDescription is not optional on an icon-only button. It's the only label a screen reader has, and null there means the control is announced as "button" with no indication of what it does.

The FAB is for the screen's single most important action

Scaffold(
    floatingActionButton = {
        ExtendedFloatingActionButton(
            onClick = ::compose,
            icon = { Icon(Icons.Default.Edit, null) },
            text = { Text("Compose") },
        )
    },
) { … }

Three sizes (SmallFloatingActionButton, FloatingActionButton, LargeFloatingActionButton) plus the extended variant with a label. The rules that matter: one per screen, it should be the action the screen exists to enable, and it should not duplicate something already in the app bar.

ExtendedFloatingActionButton is worth preferring when the icon alone is ambiguous. A pencil could mean compose, edit or draw; "Compose" cannot.

How to prove it

The emphasis question has a physical test: squint at the screen, or blur the screenshot. Whatever you still see is what the user sees first. If three things survive the blur, you have three primary actions and no hierarchy.

For touch targets, the accessibility scanner catches the real failures:

@Test fun iconButtonsMeetTouchTarget() = runComposeUiTest {
    setContent { Toolbar() }
    onNodeWithContentDescription("Share").assertTouchHeightIsEqualTo(48.dp)
}

And for the labels, turn TalkBack on and swipe through the screen once. Every icon-only control should announce something meaningful. It takes two minutes and finds every contentDescription = null that should have been a string.

The loading state, done once

Every app needs a button that shows progress, and the two common attempts both have a flaw:

// Flaw 1: the button resizes when the spinner replaces the label
if (loading) CircularProgressIndicator() else Button(onClick = ::submit) { Text("Submit") }

// Flaw 2: still clickable — a double tap submits twice
Button(onClick = ::submit) { if (loading) CircularProgressIndicator() else Text("Submit") }

The version that behaves keeps the footprint stable and disables the action:

Button(onClick = ::submit, enabled = !loading) {
    Box(contentAlignment = Alignment.Center) {
        Text("Submit", modifier = Modifier.alpha(if (loading) 0f else 1f))
        if (loading) {
            CircularProgressIndicator(
                modifier = Modifier.size(18.dp),
                strokeWidth = 2.dp,
                color = LocalContentColor.current,
            )
        }
    }
}

The label stays in the layout at zero alpha, so the button's width never changes and neighbouring content doesn't jump. enabled = false is what actually prevents the double submit — hiding the label does not.

This is worth extracting into the design system once rather than re-deriving per screen, because the two flaws above are exactly what a hurried implementation produces.

What this generalizes to

The reusable idea is that a component set can encode a design decision. Five buttons that differ only cosmetically would be a styling menu; five buttons arranged on an emphasis scale force you to answer "which action matters most here" — a question that has a right answer per screen and improves the screen whenever it's asked.

That's the same argument as Day 30's three canonical layouts. A constrained vocabulary isn't a limitation on expression; it's a prompt to make the decision the design needed anyway.

The counter-pressure is real: every design system eventually gets a request for a sixth button. Usually the honest answer is that the new one is an existing style with different colours, and adding it as a variant of Button keeps the hierarchy intact where a new name would erode it.

Tomorrow, Day 37: checkboxes, radio buttons and switches — three controls with three distinct meanings that get swapped constantly.


Day 36 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Buttons.