Pocket Studio Academy
HomePart 33.8

Buttons and click handling

Full course10 min read·3 questions

How a Button takes two lambdas rather than one, why onClick = roll() is the classic beginner error, the five Material 3 button styles and when to use each, and how to make absolutely anything tappable.

Where a poster becomes an app

Everything so far has been a picture. Text, colours, layouts — beautiful, and utterly inert. A Button is the first thing you have written that lets the person holding the phone change something.

kotlin
1Button(onClick = { score++ }) {
2    Text(text = "Roll")
3}

That is a complete, styled, ripple-animated, accessible Material 3 button. Look closely at its shape, though, because it is stranger than it looks: there are two sets of braces, and they do completely different jobs.

Think of it like this

A doorbell has two separate halves.

There is the button on the wall — a physical thing with a shape, a colour and a label saying which flat it belongs to. And there is the wire running from it to somewhere else in the building, where something actually happens.

The bell does not decide what happens. It has no idea. Its whole job is to be pressable and to send a signal down the wire.

In Compose, the content braces describe the button on the wall. The onClick braces are the wire. Keeping those two things separate is why the same Button can hold a word, a word and an icon, or a whole tiny layout, and still work identically.

Two lambdas, two jobs

kotlin
1Button(
2    onClick = { score++ },
3    modifier = Modifier.fillMaxWidth()
4) {
5    Text(text = "Roll")
6}
  • onClick is a of type () -> Unit: takes nothing, gives nothing back, runs when tapped. Compose stores it and calls it later.
  • The trailing braces are the content parameter — and it is a composable lambda. Whatever you put in there is drawn inside the button.

The second one is called a slot. Button does not have a text parameter, deliberately. Instead it leaves a hole and lets you fill it with any composables you like:

kotlin
1Button(onClick = onRoll) {
2    Icon(
3        imageVector = Icons.Default.Refresh,
4        contentDescription = null
5    )
6    Spacer(Modifier.width(8.dp))
7    Text(text = "Roll")
8}

The content of a Button is laid out as a Row, so those three things sit side by side and centred without you asking. Slots appear everywhere in Material 3 — cards, dialogs, top bars, the lot — and they are why the library is flexible without having forty parameters per composable.

The mistake everyone makes once

kotlin
1Button(onClick = roll()) {   // wrong
2    Text(text = "Roll")
3}

That does not compile, and the error is worth reading slowly:

text
1e: Type mismatch: inferred type is Unit
2   but () -> Unit was expected

roll() — with brackets — calls the function immediately and hands Button whatever it returned, which is nothing. Button did not want a result. It wanted the function itself, to keep for later.

Three ways to write it correctly:

kotlin
1Button(onClick = { roll() })  // a lambda calling it
2Button(onClick = ::roll)      // a function reference
3Button(onClick = onRoll)      // a lambda passed in

If it helps, read onClick = { roll() } as "when clicked, then roll", and onClick = roll() as "roll right now, and give the button the leftovers". Only one of those is a plan.

Five buttons, in order of loudness

Material 3 gives you a ladder of emphasis. Using the right rung is most of what makes a screen look professionally designed.

ComposableLooks likeUse it for
ButtonFilled with your primary colourThe one main action on the screen
FilledTonalButtonFilled with a soft container colourA strong secondary action
ElevatedButtonPale with a shadowA secondary action on a busy background
OutlinedButtonOutline onlyA secondary action, quieter still
TextButtonJust the labelCancel, Skip, Learn more

They all take exactly the same parameters, so swapping one for another is a one-word edit.

The rule: one filled Button per screen. If everything shouts, nothing does.

Making it yours

RollControls.ktkotlin
1@Composable
2fun RollControls(
3    rolling: Boolean,
4    onRoll: () -> Unit,
5    onReset: () -> Unit
6) {
7    Row(
8        horizontalArrangement =
9            Arrangement.spacedBy(12.dp),
10        verticalAlignment = Alignment.CenterVertically
11    ) {
12        Button(
13            onClick = onRoll,
14            enabled = !rolling,
15            shape = RoundedCornerShape(18.dp),
16            contentPadding = PaddingValues(
17                horizontal = 30.dp,
18                vertical = 14.dp
19            ),
20            colors = ButtonDefaults.buttonColors(
21                containerColor = Color(0xFF5B3FD6),
22                contentColor = Color.White
23            )
24        ) {
25            Text(text = "Roll")
26        }
27        TextButton(onClick = onReset) {
28            Text(text = "Reset")
29        }
30    }
31}
9:41▲ ▮
Roll
Reset

RollControls(rolling = false). One filled button, one text button.

Making anything tappable

Not everything that responds to a tap should look like a button. In Dice Duel, the die itself is the button:

DieFace.ktkotlin
1Canvas(
2    modifier = modifier
3        .size(size)
4        .shadow(14.dp, shape)
5        .clickable { onRoll() }
6) {
7    drawDie(value)
8}

Modifier.clickable { } adds tap handling — plus a ripple and the right accessibility behaviour — to absolutely any composable. Remember the chain rule from Lesson 3.3: put .clickable early so the padding around your content is tappable too, not just the content itself.

For an icon on its own, prefer IconButton, which gives you a 48dp tap target for free — the minimum size a finger can reliably hit:

kotlin
1IconButton(onClick = onDelete) {
2    Icon(
3        imageVector = Icons.Default.Delete,
4        contentDescription = "Delete note"
5    )
6}

That contentDescription is not optional politeness. It is the only thing a screen reader has to announce, and without it the button is a silent mystery. Lesson 6.3 goes further.

Careful

A click handler is ordinary code, not composable code. You can change , call a plain function, launch a coroutine — but you cannot call a composable in there. onClick = { MyDialog() } will not compile.

To show a dialog, set state in the click and let the body react to it:

kotlin
1var showDialog by remember { mutableStateOf(false) }
2
3Button(onClick = { showDialog = true }) {
4    Text(text = "Delete")
5}
6if (showDialog) {
7    ConfirmDialog(
8        onDismiss = { showDialog = false }
9    )
10}
Try it in Pocket Studio
  1. Open ComposeLab and type the RollControls composable from this lesson.
  2. Call it from setContent with a little state to drive it: var rolling by remember { mutableStateOf(false) }, then RollControls(rolling, onRoll = { rolling = true }, onReset = { rolling = false }).
  3. Accept the imports. New ones: androidx.compose.material3.Button, androidx.compose.material3.ButtonDefaults, androidx.compose.material3.TextButton, androidx.compose.foundation.layout.PaddingValues and androidx.compose.foundation.shape.RoundedCornerShape.
  4. Tap Run. Tap Roll — it greys out. Tap Reset — it comes back. That is enabled wired to state, which is the pattern behind every "please wait" button you have ever seen.
  5. Now break it: change onClick = onRoll to onClick = onRoll(). Try to build and read the type-mismatch error. Change it back.
  6. Swap the word Button for OutlinedButton and Run. Then TextButton. Same code, three levels of emphasis.
  7. Add an Icon and a Spacer(Modifier.width(8.dp)) before the Text inside the button's braces, and Run. The slot took it without complaint.
Error Doctor5 common errors
e: Type mismatch: inferred type is Unit but () -> Unit was expected
MeansYou wrote onClick = roll() with brackets. That calls roll immediately and hands the button its result — nothing. The button wanted the function itself, to keep for later.
FixWrap it in braces: onClick = { roll() }. Or pass a reference with no brackets: onClick = ::roll.
e: No value passed for parameter 'content'
MeansYou wrote Button(onClick = { ... }) and stopped. A Button has no default label — the content slot is required, and it is the trailing braces.
FixAdd the content block: Button(onClick = { ... }) { Text("Roll") }. Watch the closing bracket of the parameter list — the braces go after it, not inside it.
e: @Composable invocations can only happen from the context of a @Composable function
MeansYou called a composable inside onClick. Click handlers run long after composition has finished, so there is no composition for them to add anything to.
FixSet state in the handler, and put the composable in the body guarded by that state: if (showDialog) { ConfirmDialog(...) }.
e: Unresolved reference: ButtonDefaults
MeansButtonDefaults holds the standard colours and sizes for every button type, and it is a separate import from Button.
FixAdd import androidx.compose.material3.ButtonDefaults. Each button type has its own factory on it: buttonColors, textButtonColors, outlinedButtonColors.
The button label is invisible after I set my own colours
MeansYou set containerColor and left contentColor at its default, so the label is still the colour designed for the old background. White on white, or violet on violet.
FixAlways set both together in ButtonDefaults.buttonColors(containerColor = ..., contentColor = ...). Lesson 3.11 shows how theme roles pair them for you automatically.
Recap
  • A Button takes two lambdas: onClick (the wire) and a trailing content slot (the label).
  • onClick = roll() calls the function immediately and fails to compile. onClick = { roll() } is what you meant.
  • Five styles in order of loudness: Button, FilledTonalButton, ElevatedButton, OutlinedButton, TextButton. One filled button per screen.
  • enabled = false is the right way to block an action, not an if inside the handler.
  • contentPadding sizes the inside of a button; Modifier.padding spaces the outside.
  • Modifier.clickable { } makes anything tappable; IconButton gives icons a finger-sized target and takes a .
  • Click handlers are ordinary code. Set there and let the body react.
  • Next: text fields — reading what the user types, and why the field appears frozen if you forget one line.