clickable does six things, and detectTapGestures does one
Modifier.clickable adds accessibility semantics, focus, keyboard activation, ripple and a minimum touch target on top of tap detection. Reaching for detectTapGestures instead silently drops all of it.

Day 72 of 100. The most common gesture, and a choice between two APIs that look interchangeable and are not.
The symptom
A custom tappable card that feels dead:
Box(
Modifier
.pointerInput(item.id) {
detectTapGestures { onOpen(item.id) }
}
.background(surface, shape)
) { CardContent(item) }
It responds to taps and nothing else happens. No ripple, so there's no feedback that the touch registered. TalkBack announces it as plain content with no indication it's interactive. A keyboard user can't reach it, and pressing Enter does nothing.
The tap works. Everything around the tap is missing.
Why the obvious fix fails
The obvious fix is to add the missing pieces:
Box(
Modifier
.pointerInput(item.id) { detectTapGestures { onOpen(item.id) } }
.indication(interactionSource, ripple())
.semantics { role = Role.Button; onClick { onOpen(item.id); true } }
.focusable()
) { … }
Four modifiers reproducing what one already does — and still missing the minimum touch
target, the keyboard Enter/Space handling, and the enabled state that should suppress
all of it at once.
clickable isn't a thin wrapper. It's the assembly.

The actual mechanism
Modifier.clickable composes six behaviours:
Modifier.clickable(
enabled = true,
onClickLabel = "Open message",
role = Role.Button,
onClick = { onOpen(item.id) },
)
- Tap detection, including the press-release-within-bounds rules.
- Semantics —
Role.Button, anonClickaction, the merge from Day 68. - Indication — the ripple, driven by an
InteractionSource. - Focusability — reachable by keyboard and D-pad.
- Keyboard activation — Enter and Space fire
onClick. - Minimum touch target — 48dp via
minimumInteractiveComponentSize.
detectTapGestures provides the first. That's the entire difference, and it explains why
the fix list above was four modifiers long and still incomplete.
The rule: use clickable unless you need a gesture it cannot express. Double-tap,
long-press-with-position, and press-and-hold-with-drag are real reasons; "I only need a
tap" is not.
onClickLabel is the parameter to remember
Modifier.clickable(onClickLabel = "Open message") { … }
This changes what TalkBack announces for the action — "double tap to open message" rather than "double tap to activate". On a list of cards that all announce their content and then "activate", the label is what tells the user what activating does.
It costs one string and is the single highest-value accessibility parameter in the gestures pillar.
Long press and double tap
combinedClickable extends the same assembly:
Modifier.combinedClickable(
onClick = { open(item) },
onLongClick = { showContextMenu(item) },
onLongClickLabel = "Show options",
onDoubleClick = { toggleFavourite(item) },
)
It keeps the semantics, focus and indication, and adds long-press and double-tap semantics too — so TalkBack surfaces the long-press action in its menu rather than requiring a gesture nobody can perform.
Two design notes worth carrying. A long press should never be the only way to reach a function — Day 67's rule. And a double tap conflicts with TalkBack, where double-tap is the activation gesture, so a double-tap-only feature is unreachable for those users.
There's a latency cost too, and it's worth knowing before adding onDoubleClick
everywhere. To detect a double tap, the framework must wait to see whether a second tap
arrives — so a component with a double-tap handler delays its single tap response by
that window. On a list where tapping opens an item, that delay is perceptible.
The trade is usually only worth it where the double tap is genuinely secondary and the single tap is not time-critical: a photo that opens on tap and zooms on double tap, for instance, where both are viewing actions.
InteractionSource: reading the press
When you want to react to press state — scale a card down while held, change elevation — the interaction source is the seam:
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "scale")
Box(
Modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.clickable(interactionSource = interactionSource, indication = ripple()) { open() }
)
collectIsPressedAsState, collectIsFocusedAsState, collectIsHoveredAsState and
collectIsDraggedAsState cover the states you'd want. Passing the same source to
clickable and to the visual response is what keeps them in sync.
Note the animation reads in the draw phase via graphicsLayer — Day 62's point, and it
matters here because press feedback runs on every touch.
Removing the ripple, correctly
Sometimes the ripple is wrong — a full-bleed image, a custom press animation:
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { open() }
indication = null removes the visual and keeps the other five behaviours. That's the
important distinction: dropping to detectTapGestures to lose a ripple also loses
accessibility and focus, and it's a trade nobody intends to make.
If you're removing the ripple, provide some feedback — the scale animation above, a colour change, a haptic. A control with no press feedback feels broken even when it works.
Haptics deserve a mention because they're one line and routinely skipped:
val haptics = LocalHapticFeedback.current
Modifier.combinedClickable(
onClick = { open(item) },
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
showContextMenu(item)
},
)
A long press with no haptic gives the user no signal that the threshold was reached, so they hold longer than needed or release too early. The platform provides the feedback type; using it keeps your app consistent with every other app on the device.
How to prove it
The gap is directly testable, and the test is short:
@Test fun cardIsAccessibleAndFocusable() = runComposeUiTest {
setContent { ItemCard(sample, onOpen = {}) }
onNodeWithTag("card")
.assert(hasClickAction())
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Role))
.assertTouchHeightIsEqualTo(48.dp)
}
Against the detectTapGestures version, all three assertions fail. Against clickable,
all three pass without any extra code — which is the argument in one test.
On device, three checks: tap and look for the ripple, tab to it with a keyboard, and swipe to it with TalkBack. A control that fails any of them is using the detector where the modifier belonged.
What this generalizes to
The principle is the interaction is more than the event. A tap is a pointer sequence; a button is a tap plus feedback plus a name plus a keyboard path plus a target size. Building the first and calling it the second is how interfaces end up technically functional and practically unusable.
It's the same relationship as Day 37's Switch versus a styled checkbox, and Day 43's
filter chip versus an assist chip. The library's higher-level component isn't a convenience
wrapper — it's the accumulated set of things the lower level doesn't know it should do.
Which suggests a useful reading habit for any component library: when a high-level API and a low-level one both seem to do the job, the interesting question is what the high-level one adds that you'd have to remember. That list is usually written down somewhere, and it is usually longer than expected.
Tomorrow, Day 73: drag, swipe and fling — gestures with velocity, and the anchored state that makes swipe-to-dismiss work.
Day 72 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Tap and press.