
Sometimes our apps need to highlight some UI components, e.g. during the first login (onboarding), or when we added something new (“what’s new”).
In this article I will guide on building a custom solution to show hints / tooltips, pointing to a particular UI element in Compose (Compose Multiplatform and Jetpack Compose).
Layout hierarchy
To highlight a UI element firstly we should get through the main concept of “hints”.
Let’s imagine we have an app with TopBar
, BottomNavigation
, and a primary action button. We want to highlight a TopBar’s action
, the primary button, and an item from BottomNavigation.

1. Dimmed background
To draw a dimmed background as overlay or popup on top of all content we might:
- Wrap all content of our app (root component) with a custom composable (e.g.
Box
withModifier.background
)
@Composable | |
fun AppContent() { | |
HintOverlay { | |
MaterialTheme { | |
Scaffold { | |
// My app content | |
} | |
} | |
} | |
} |
2. Use Dialog
By using Dialog we can show an overlay on top of all content (e.g. on Android the
Dialog
is shown in its own Window).
With Dialog
the problem comes for scrimColor
in Compose Multiplatform. We can’t configure from Compose common target the scrimColor
, but each target (except for Android) provides an actual
value for the scrimColor
. As a possible solution, we can create an excepted class
to provide DialogProperties
and provide actual
implementation for each target.
3. Use Popup
Popup
looks more better here because it does not draw a scrimColor
by default, and the overlay is shown on top of all content.
I would go with the 3nd approach not to force to use HintOverlay
manually.
I also want to add support of Brush for the overlay background, not only
Color.
val LocalHintOverlayColor = staticCompositionLocalOf<Color> { Color(0x44000000) } | |
val LocalHintOverlayBrush = staticCompositionLocalOf<Brush?> { null } | |
@Composable | |
fun HintOverlay() { | |
Popup { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.overlayBackground() | |
) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), | |
text = "Draw hints here", | |
color = Color.White, | |
) | |
} | |
} | |
} | |
/** | |
* Set `background` either from [LocalHintOverlayBrush] or from [LocalHintOverlayColor]. | |
*/ | |
private fun Modifier.overlayBackground(): Modifier = composed { | |
LocalHintOverlayBrush.current?.let { background(it) } | |
?: background(LocalHintOverlayColor.current) | |
} |
I can set the Brush
to override the overlay’s background by using CompositionLocalProvider.
CompositionLocalProvider( | |
LocalHintOverlayBrush provides Brush.linearGradient( | |
listOf( | |
Color.Red.copy(alpha = 0.5f), | |
Color.Blue.copy(alpha = 0.5f), | |
) | |
), | |
) { | |
HintOverlay() | |
} |

2. Calculate anchor coordinates
To get coordinates of an UI element in Compose we can use OnGloballyPositionedModifier, which is called with the final
LayoutCoordinates of the Layout when the global position of the content may have changed.
Usage example:
Column( | |
Modifier.onGloballyPositioned { coordinates -> | |
// This will be the size of the Column. | |
coordinates.size | |
// The position of the Column relative to the application window. | |
coordinates.positionInWindow() | |
// The position of the Column relative to the Compose root. | |
coordinates.positionInRoot() | |
// These will be the alignment lines provided to the layout (empty here for Column). | |
coordinates.providedAlignmentLines | |
// This will be a LayoutCoordinates instance corresponding to the parent of Column. | |
coordinates.parentLayoutCoordinates | |
} | |
) { | |
Box(Modifier.size(20.dp).background(Color.Green)) | |
Box(Modifier.size(20.dp).background(Color.Blue)) | |
} |
For our hints we need to create a state to hold coordinates and size of anchors, and introduce a Modifier
to update the state:
@Stable | |
class HintAnchorState internal constructor() { | |
internal var size: IntSize by mutableStateOf(IntSize.Zero) | |
internal var offset: Offset by mutableStateOf(Offset.Zero) | |
} | |
@Composable | |
fun rememberHintAnchorState(): HintAnchorState { | |
return remember { HintAnchorState() } | |
} | |
fun Modifier.hintAnchor(state: HintAnchorState): Modifier { | |
return onGloballyPositioned { | |
state.size = it.size | |
state.offset = it.positionInWindow() | |
} | |
} |
So, we just subscribed to size and coordinates changes of a desired UI element to update the anchor’s state.
Now we need to apply this hintAnchor
modifier to our content:
val topAppBarActionHintAnchor = rememberHintAnchorState() | |
val actionHintAnchor = rememberHintAnchorState() | |
val bottomNavigationHintAnchor = rememberHintAnchorState() | |
IconButton( | |
modifier = Modifier | |
.hintAnchor(topAppBarActionHintAnchor), | |
onClick = {}, | |
) | |
Button( | |
modifier = Modifier | |
.hintAnchor(actionHintAnchor) | |
.padding(4.dp), | |
onClick = {}, | |
) { | |
Text("Action") | |
} | |
BottomNavigationItem( | |
modifier = Modifier | |
.hintAnchor(topAppBarActionHintAnchor), | |
//...pass other required params | |
) |
Note:
Modifier
ordering always matter in Compose, we set4.dp
afterhintAnchor
to have extra space around this button (the anchor’s size will be bigger by4.dp
that the actual button’s size).
The HintOverlay
composable requires some changes to use the HintAnchorState
to draw hints for this anchor.
@Composable | |
fun HintOverlay( | |
anchors: () -> List<HintAnchorState>, | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.overlayBackground(anchors) | |
) | |
} | |
/** | |
* Set `background` either from [LocalHintOverlayBrush] or from [LocalHintOverlayColor]. | |
*/ | |
private fun Modifier.overlayBackground( | |
anchors: () -> List<HintAnchorState>, | |
): Modifier = composed { | |
val backgroundBrush = LocalHintOverlayBrush.current | |
val backgroundColor = LocalHintOverlayColor.current | |
drawWithCache { | |
onDrawWithContent { | |
if (backgroundBrush != null) { | |
drawRect(backgroundBrush) | |
} else { | |
drawRect(backgroundColor) | |
} | |
anchors().forEach { anchor -> | |
drawRect( | |
color = Color.Red, | |
topLeft = anchor.offset, | |
size = anchor.size.toSize(), | |
style = Stroke(width = 5f), | |
) | |
} | |
drawContent() | |
} | |
} | |
} |
For now we just draw a red rectangle around out anchors.

But if we run on mobile, we get wrong numbers on Android.

With WindowInsets
The issue is related to WindowInsets
. Let’s substract these insets to fix it.
fun Modifier.hintAnchor(state: HintAnchorState): Modifier = composed { | |
val statusBarInsets = WindowInsets.statusBars.getTop(LocalDensity.current).toFloat() | |
onGloballyPositioned { | |
state.size = it.size | |
state.offset = it.positionInWindow() | |
// To fix WindowInsets on Android | |
.minus(Offset(x = 0f, y = statusBarInsets)) | |
} | |
} |

Fixed WindowInsets
3. Clip out anchor’s shape
To clip our a shape we will use Path and
PathOperation.
Modify the hintAnchor
Modifier to accept Shape
, which will be used to set a desired shape around anchors.
fun Modifier.hintAnchor( | |
state: HintAnchorState, | |
shape: Shape = RectangleShape, | |
): Modifier { | |
state.shape = shape | |
//..onGloballyPositioned | |
} | |
@Stable | |
class HintAnchorState internal constructor() { | |
//...other states here | |
internal var shape: Shape by mutableStateOf(RectangleShape) | |
} |
Based on the provided Shape
we can create anOutline which will be used to clip the anchor’s shape out of the background.
internal fun Modifier.overlayBackground( | |
anchors: () -> List<HintAnchorState>, | |
): Modifier = composed { | |
val backgroundBrush = LocalHintOverlayBrush.current | |
val backgroundColor = LocalHintOverlayColor.current | |
val layoutDirection = LocalLayoutDirection.current | |
val density = LocalDensity.current | |
drawWithCache { | |
// Prepare path for background | |
val path = Path().apply { | |
lineTo(size.width, 0f) | |
lineTo(size.width, size.height) | |
lineTo(0f, size.height) | |
lineTo(0f, 0f) | |
close() | |
} | |
anchors().forEach { anchor -> | |
// Prepare path for the anchor | |
val anchorPath = Path() | |
anchorPath.addOutline( | |
anchor.shape.createOutline( | |
size = anchor.size.toSize(), | |
layoutDirection = layoutDirection, | |
density = density, | |
) | |
) | |
anchorPath.translate(anchor.offset) | |
anchorPath.close() | |
// Clip out the anchor | |
path.op(path, anchorPath, PathOperation.Xor) | |
} | |
onDrawWithContent { | |
// Not we just draw path and not a rect as previously | |
if (backgroundBrush != null) { | |
drawPath(path, backgroundBrush) | |
} else { | |
drawPath(path, backgroundColor) | |
} | |
drawContent() | |
} | |
} | |
} |
Let’s pass CircleShape
and RoundedCornerShape
to see how the hints look now.

At this point we know how to draw a background overlay, calculate positions of our anchors, and how to clip out the background.
4. Draw hints
Before the actual drawing we should define what information hints need to present.
Not to force providing only a text, let’s go with “slot” approach. By defining a “slot” we allow to use any desired composable.
I will introduce a new class Hint
to hold our Composable
content.
@Stable | |
class Hint internal constructor() { | |
internal var content: @Composable () -> Unit by mutableStateOf({}) | |
} | |
@Composable | |
fun rememberHint(content: @Composable () -> Unit): Hint { | |
return remember { | |
Hint().also { it.content = content } | |
} | |
} |
And will add this Hint
to be part of HintAnchorState
.
@Stable | |
class HintAnchorState internal constructor( | |
internal val hint: Hint, | |
) { | |
//...other states here | |
} | |
@Composable | |
fun rememberHintAnchorState(hint: Hint): HintAnchorState { | |
return remember(hint) { | |
HintAnchorState(hint) | |
} | |
} |
Inside HintOverlay
we might go with the simplest solution — BoxWithConstrains
.
@Composable | |
fun HintOverlay( | |
anchors: () -> List<HintAnchorState>, | |
) { | |
//... | |
BoxWithConstraints( | |
modifier = Modifier | |
.fillMaxSize() | |
.overlayBackground(anchors) | |
) { | |
anchors().forEach { anchor -> | |
Box( | |
modifier = Modifier | |
.graphicsLayer { | |
translationX = anchor.offset.x | |
translationY = anchor.offset.y + anchor.size.height | |
}, | |
) { | |
anchor.hint.content() | |
} | |
} | |
} | |
} |
Modify the app content.
val topAppBarHint = rememberHint { | |
OutlinedButton(onClick = {}) { Text("Hint for TopAppBar") } | |
} | |
val topAppBarActionHintAnchor = rememberHintAnchorState(topAppBarHint) | |
val actionHint = rememberHint { | |
Text("Hint for Action") | |
} | |
val actionHintAnchor = rememberHintAnchorState(actionHint) | |
val bottomNavigationHint = rememberHint { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
Spacer(Modifier.size(32.dp).background(Color.Magenta, CircleShape)) | |
Spacer(Modifier.size(8.dp)) | |
Text("Hint for BottomNavigation") | |
} | |
} | |
val bottomNavigationHintAnchor = rememberHintAnchorState(bottomNavigationHint) |
The result will be following.

Let’s introduce an app specific code to draw backgrounds for out hints.
@Composable | |
fun rememberHintContainer(content: @Composable () -> Unit): Hint { | |
return rememberHint { | |
Box( | |
modifier = Modifier | |
.padding(top = 8.dp) | |
.background(Color.Yellow, shape = RoundedCornerShape(16.dp)) | |
.padding(16.dp), | |
) { | |
CompositionLocalProvider( | |
LocalTextStyle provides TextStyle( | |
color = Color.Black, | |
fontSize = 12.sp, | |
fontWeight = FontWeight.Light, | |
), | |
) { | |
content() | |
} | |
} | |
} | |
} |

We got 2 issues:
- Horizontal alignment, the hint should be center align to its anchor.
- Hint for BottomNavigation is out of the screen.
Let’s switch to a custom layout instead and fix those issues there.
To measure and layout multiple composables, use the Layout composable. This composable allows us to measure and lay out children manually. All higher-level layouts like
Column
and Row
are built with the Layout
composable.
@Composable | |
internal fun HintsContainer( | |
modifier: Modifier, | |
anchors: () -> List<HintAnchorState>, | |
) { | |
val anchors = anchors() | |
Layout( | |
modifier = modifier | |
.overlayBackground(anchors), | |
content = { | |
anchors.forEach { it.hint.content() } | |
}, | |
) { measurables, constraints -> | |
// Measure each hint | |
val placeables = measurables.map { measurable -> | |
measurable.measure( | |
constraints.copy(minWidth = 0, minHeight = 0) | |
) | |
} | |
// Set the size of the layout as big as it can | |
layout(constraints.maxWidth, constraints.maxHeight) { | |
// Place each hint relatively to it's anchor | |
placeables.forEachIndexed { index, placeable -> | |
val anchor = anchors[index] | |
// Center align this hint | |
val x = (anchor.offset.x.toInt() - (placeable.width - anchor.size.width) / 2) | |
// Fix the coordinate if it's out of the screen | |
.coerceAtLeast(0) | |
.coerceAtMost(constraints.maxWidth - placeable.width) | |
// Put this hint below its anchor | |
var y = (anchor.offset.y.toInt() + anchor.size.height) | |
// Fix y-coordinate if it's out of the screen | |
.coerceAtMost(constraints.maxHeight - placeable.height) | |
if (y < anchor.offset.y + anchor.size.height) { | |
// Hint is be overlapping its anchor, put this hint above its anchor | |
y = anchor.offset.y.toInt() - placeable.height | |
} | |
placeable.placeRelative(x = x, y = y) | |
} | |
} | |
} | |
} |

Because we allowed to pass any composable to be as a hint, the caller has all control on how the hints will look like, e.g. we can use just Text
, or a complex Row
with many children.
5. How to control hints?
We statically added our hints to show them on the screen. But it’s not the case for production apps. Let’s introduce HintController
to control when we want to show the hints.
@Stable | |
class HintController internal constructor() { | |
internal var hint by mutableStateOf<HintAnchorState?>(null) | |
fun show(hint: HintAnchorState) { | |
this.hint = hint | |
} | |
} | |
@Composable | |
fun rememberHintController(): HintController { | |
val controller = remember { HintController() } | |
controller.hint?.let { hint -> | |
HintOverlay(anchor = hint) | |
} | |
return controller | |
} |
Modify the app content to show hints when we click on anchors.
val hintController = rememberHintController() | |
IconButton( | |
modifier = Modifier | |
.hintAnchor(topAppBarActionHintAnchor, CircleShape), | |
onClick = { | |
hintController.show(topAppBarActionHintAnchor) | |
}, | |
) | |
BottomNavigationItem( | |
modifier = Modifier | |
.hintAnchor( | |
bottomNavigationHintAnchor, | |
shape = RoundedCornerShape(50f), | |
), | |
onClick = { | |
hintController.show(bottomNavigationHintAnchor) | |
}, | |
) | |
Button( | |
modifier = Modifier | |
.hintAnchor(actionHintAnchor, RoundedCornerShape(16.dp)) | |
.padding(4.dp), | |
onClick = { | |
hintController.show(actionHintAnchor) | |
}, | |
) |
Note: we no longed need to
HintOverlay
, it becomesinternal
now.

Now we can show hints one by one, but there are 2 missings parts: how to dismiss hints, and how to override the overlay color.
Make changes to HintController
to allow passing the overlay color.
@Composable | |
fun rememberHintController(overlay: Brush): HintController { | |
return rememberHintController(overlay = LocalHintOverlayBrush provides overlay) | |
} | |
@Composable | |
fun rememberHintController(overlay: Color = HintOverlayColorDefault): HintController { | |
return rememberHintController(overlay = LocalHintOverlayColor provides overlay) | |
} | |
@Composable | |
private fun rememberHintController(overlay: ProvidedValue<*>): HintController { | |
val controller = remember { HintController() } | |
controller.hint?.let { hint -> | |
CompositionLocalProvider(overlay) { | |
HintOverlay(anchor = hint) | |
} | |
} | |
return controller | |
} |
To allow dismissing our hints let’s introduce following changes.
@Stable | |
class HintController internal constructor() { | |
internal var hint by mutableStateOf<HintAnchorState?>(null) | |
fun show(hint: HintAnchorState) { | |
this.hint = hint | |
} | |
fun dismiss() { | |
hint = null | |
} | |
} |
We used Popup
as a container for our overlay, and the Popup
can be dismissed if users click back button on Android.
@Composable | |
internal fun HintOverlay( | |
anchor: HintAnchorState, | |
onDismiss: () -> Unit, | |
) { | |
Popup( | |
onDismissRequest = onDismiss, | |
// Set focusable to handle back press events | |
properties = remember { PopupProperties(focusable = true) }, | |
) { | |
//...draw our hints here | |
} | |
} | |
@Composable | |
internal fun HintsContainer( | |
modifier: Modifier, | |
anchor: HintAnchorState, | |
onDismiss: () -> Unit, | |
) { | |
Layout( | |
modifier = modifier | |
.overlayBackground(anchor) | |
.clickable( | |
interactionSource = null, | |
// Disable ripple | |
indication = null, | |
onClick = onDismiss, | |
) | |
) | |
} |
Now the HintController
allows us to show one hint by time, but there is no actual queue in case we want to show many hints sequentially.
Extend the HintController
and add suspend modifier to know when a hint was shown (e.g. do something right after it’s shown).
@Stable | |
class HintController internal constructor() { | |
private var queue = mutableStateListOf<HintAnchorState>() | |
internal val hint: HintAnchorState? get() = queue.firstOrNull() | |
private val pendingRequests = mutableMapOf<HintAnchorState, Continuation<Unit>>() | |
suspend fun show(hint: HintAnchorState) { | |
suspendCoroutine { continuation -> | |
pendingRequests[hint] = continuation | |
queue.add(hint) | |
} | |
} | |
suspend fun show(vararg hint: HintAnchorState) { | |
show(hint.toList()) | |
} | |
suspend fun show(hints: List<HintAnchorState>) { | |
suspendCoroutine { continuation -> | |
pendingRequests[hints.last()] = continuation | |
queue.addAll(hints) | |
} | |
} | |
internal fun onDismissed(hint: HintAnchorState) { | |
pendingRequests[hint]?.let { continuation -> | |
continuation.resume(Unit) | |
pendingRequests.remove(hint) | |
} | |
queue.remove(hint) | |
} | |
fun dismiss() { | |
pendingRequests.values | |
.forEach { continuation -> | |
continuation.resumeWithException(CancellationException("Hint was dismissed")) | |
} | |
pendingRequests.clear() | |
queue.clear() | |
} | |
} |
Now to show a hint inside app we need to have a CoroutineScope
.
val coroutineScope = rememberCoroutineScope() | |
val hintController = rememberHintController() | |
// Now we can dismiss all pending hints from a hint itself | |
val topAppBarHint = rememberHintContainer { | |
OutlinedButton( | |
onClick = { | |
hintController.dismiss() | |
} | |
) { Text("Hint for TopAppBar") } | |
} | |
// Show 1 hint | |
BottomNavigationItem( | |
onClick = { | |
coroutineScope.launch { | |
hintController.show(bottomNavigationHintAnchor) | |
scaffoldState.snackbarHostState.showSnackbar("One hint was shown") | |
} | |
}, | |
) | |
// Show many hints sequentially | |
Button( | |
onClick = { | |
coroutineScope.launch { | |
hintController.show( | |
topAppBarActionHintAnchor, | |
actionHintAnchor, | |
bottomNavigationHintAnchor, | |
) | |
scaffoldState.snackbarHostState.showSnackbar("Many hints were shown") | |
} | |
}, | |
) |
Note: if we dismiss a hint by calling
hintController.dismiss()
, code afterhintController.show
will not be called.
coroutineScope.launch { hintController.show(topAppBarActionHintAnchor) // Snackbar will not be shown if the // previous hint was dismissed by calling hintController.dismiss scaffoldState.snackbarHostState.showSnackbar("One hint was shown") }
The final result is following: we can show a single hint, and we can show a list of hints.
Desktop
Since the project is using Compose Multiplatform, we can run the app for different targets.
Android
iOS
Job Offers
Summary
Compose and Kotlin Multiplatform is a powerful combination which allows us to use Kotlin for UI and for business logic. CMP libraries are fully compatible with Jetpack Compose Android projects only.
Check out my library on GitHub:
Thank you for reading, looking forward to getting your stars on GitHub 🙂
This article is previously published on proandroiddev.com.