Introduction
Tooltips are one of the best ways to improve user experience in modern mobile development. As the product evolves, new features will be introduced, some of which might be visible on the home screen or tucked away in the nested screens. Additionally, hidden features like long clicks, swipe gestures, etc to perform certain actions that users won’t be aware of unless informed.
The better approach would be to catch the user’s attention with a simple yet well-designed informative user interface. There are many solutions to this like introducing a What’s New Screen, etc. But tooltips are the best approach for this because of three reasons:
- Focused Attention: Tooltips capture the user’s attention by highlighting a specific component and dimming the rest of the screen, ensuring that the user is immediately drawn to the feature being explained.
- Contextual Guidance: Tooltips help users quickly understand the placement and purpose of key entry points by highlighting the relevant component.
- Rich Content Delivery: Tooltips can incorporate text, images, and even animations, providing a comprehensive explanation of features without overwhelming the user.
Now let’s move to the technical part of the implementation. We’ll use Modifier in Compose to implement tooltips, because it’s easy to draw shapes, overlays, etc, and integration in the feature-level composables will be concise.
Design The Tooltip
Let’s create a tooltip extension function on the compose modifier with the return type as the Modifier to apply at the feature level. This will be the entry point to show the tooltip from the feature-level composables. Have a look:
@Composable fun Modifier.tooltip( ): Modifier { return this }
Let’s start with the design, for the sake of simplicity, we’ll have a title, and description, nothing fancy just a regular tooltip design. For this, we need a few parameters such as title, description, max width, text alignment, arrow alignment, and dismiss listener, have a look:
@Composable fun Modifier.tooltips( title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, maxWidth: Dp = Dp.Unspecified, onDismiss: () -> Unit = {} ): Modifier { return this }
Now let’s design the tooltip view, let’s name it as ArrowTooltip
. Have a look at the implementation:
@Composable fun ArrowTooltip( modifier: Modifier = Modifier, title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, ) { Column( modifier = modifier, horizontalAlignment = arrowAlignment, ) { Icon( modifier = Modifier .padding( start = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp, end = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp ), painter = painterResource(id = R.drawable.ic_arrow_up), contentDescription = "Tool tip Arrow", tint = MaterialTheme.colorScheme.surfaceContainer, ) Column( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp), ) { Text( modifier = Modifier .fillMaxWidth(), text = title ?: "", color = MaterialTheme.colorScheme.primary, textAlign = textAlign, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium ) ) Spacer(modifier = Modifier.height(4.dp)) Text( modifier = Modifier .fillMaxWidth(), text = text, textAlign = textAlign, style = MaterialTheme.typography.bodySmall ) } } }
Let’s be good developers and write the preview function to see the output. Have a look:
@Composable @Preview fun previewArrowTooltip() { ArrowTooltip( title = "Long Press to Edit", text = "You can edit and delete the category by long press of any item in the list." ) }

tooltip preview
Of course we can add a cross icons, images, or pretty much anything based on the requirement or to match the application theme. But remember to keep it simple and informative as a thumb rule.
Now we need to invoke ArrowTooltip
in tooltip
modifier extension. But remember tooltips are not just another view in the screen hierarchy, they have to be elevated over the normal view hierarchy and should be able to dismiss on touch. What better way to achieve it than to use the Compose Popup
.
As we all know in compose every UI update is based on the state. So we need to introduce another boolean parameter in tooltip
, based on which we’ll show/hide the visibility of the tool tip popup. Also, add the padding values to align the tooltip with the screen padding. Have a look:
@Composable fun Modifier.tooltips( title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, maxWidth: Dp = Dp.Unspecified, enabled: Boolean = true, paddingValues: PaddingValues = PaddingValues(), onDismiss: () -> Unit = {} ): Modifier { if (enabled) { Popup( alignment = Alignment.TopEnd ) { Box( modifier = Modifier .fillMaxSize() .clickable( onClick = { onDismiss() } ) ) { ArrowTooltip( modifier = Modifier .widthIn(max = maxWidth) .padding(paddingValues), title = title, text = text, arrowAlignment = arrowAlignment, textAlign = textAlign ) } } } return this }
We’re invoking onDismiss
if the user clicks anywhere on the popup.
Draw Overlay And Highlight The Component
Now that we’re done with designing the tooltip and displaying it via popup, it’s time to draw and overlay between the screen view hierarchy and the popup so that it’ll be visible on top of the screen then highlight the view in which the tooltip modifier was invoked.
This is where it gets interesting, let’s start with the parameters we need to add to the tooltip
extension.
showOverlay
: Flag to determine if we need to show the overlay behind the popup or now.highlightComponent
: Flag to determine if the component to which we are pointing the tooltip is to be highlighted or not.
Have a look at the function signature after adding these parameters.
@Composable fun Modifier.tooltip( title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, maxWidth: Dp = Dp.Unspecified, enabled: Boolean = true, paddingValues: PaddingValues = PaddingValues(), horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalAlignment: Alignment.Vertical = Alignment.Top, showOverlay: Boolean = true, highlightComponent: Boolean = true, onDismiss: () -> Unit = {} )
Now the crucial part of overlay draw and highlighting the component. To keep the code organized, let’s create a modifier extension drawOverlayBackground.
This function requires a few parameters which are pretty much self-explanatory, have a look:
private fun Modifier.drawOverlayBackground( showOverlay: Boolean, highlightComponent: Boolean, positionInRoot: IntOffset, componentSize: IntSize, backgroundColor: Color, backgroundAlpha: Float ) : Modifier
positionInRoot
and componentSize
are two parameters that we need to discuss.
positionInRoot
is the IntOffset that determines the position of the component in the view hierarchy.componentSize
The size of the composable to which we are applying the tooltip.
Have a look at the core compose logic to draw the background and highlight the component based on the inputs:
private fun Modifier.drawOverlayBackground( showOverlay: Boolean, highlightComponent: Boolean, positionInRoot: IntOffset, componentSize: IntSize, backgroundColor: Color, backgroundAlpha: Float ) : Modifier { return if (showOverlay) { if (highlightComponent) { drawBehind { val highlightPath = Path().apply { addRect(Rect(positionInRoot.toOffset(), componentSize.toSize())) } clipPath(highlightPath, clipOp = ClipOp.Difference) { drawRect(SolidColor(backgroundColor.copy(alpha = backgroundAlpha))) } } } else { background(backgroundColor.copy(alpha = backgroundAlpha)) } } else { this } }
Now we need to use this in the tooltip
extension. We can determine the value of positionInRoot
and componentSize
via onPlaced
modifier extension. onPlaced
will be invoked after the parent layout is successfully placed in the view hierarchy with the information of its placement and size. Have a look:
@Composable fun Modifier.tooltips( title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, maxWidth: Dp = Dp.Unspecified, enabled: Boolean = true, paddingValues: PaddingValues = PaddingValues(), showOverlay: Boolean = true, highlightComponent: Boolean = true, onDismiss: () -> Unit = {} ): Modifier { var positionInRoot by remember { mutableStateOf(IntOffset.Zero) } var componentSize by remember { mutableStateOf(IntSize(0, 0)) } if (enabled) { Popup( alignment = Alignment.TopEnd ) { Box( modifier = Modifier .fillMaxSize() .drawOverlayBackground( showOverlay = showOverlay, highlightComponent = highlightComponent, positionInRoot = positionInRoot, componentSize = componentSize, backgroundColor = MaterialTheme.colorScheme.surfaceContainerLowest, backgroundAlpha = 0.8f ) .clickable( onClick = { onDismiss() } ) ) { ArrowTooltip( modifier = Modifier .widthIn(max = maxWidth) .padding(paddingValues), title = title, text = text, arrowAlignment = arrowAlignment, textAlign = textAlign ) } } } return this then Modifier.onPlaced { componentSize = it.size positionInRoot = it.positionInRoot().round() } }
As positionInRoot
and componentSize
are the states once they are updated from the onPlaced
extension the background will be drawn without any delay.
Tooltip Placement
This is the final part of the core logic of designing the tooltip. The logic we’ll focus on is tooltip placement on the popup. We need to add two more parameters to tooltip:
horizontalAlignment
: To determine the horizontal alignment of the popup on the screen.verticalAlignment
: To determine the vertical alignment of the popup on the screen.
Have a look at the final signature of the function:
@Composable fun Modifier.tooltip( title: String? = null, text: String, arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, textAlign: TextAlign = TextAlign.Center, maxWidth: Dp = Dp.Unspecified, enabled: Boolean = true, paddingValues: PaddingValues = PaddingValues(), showOverlay: Boolean = true, highlightComponent: Boolean = true, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalAlignment: Alignment.Vertical = Alignment.Top, onDismiss: () -> Unit = {} )
To determine the placement we need screen dimensions. In Compose we achieve this via LocalDensity and
LocalConfiguration. Have a look:
val configuration = LocalConfiguration.current val density = LocalDensity.current val screenWidthPx = remember { with(density) { configuration.screenWidthDp.dp.roundToPx() } } val screenHeightPx = remember { with(density) { configuration.screenHeightDp.dp.roundToPx() } }
We also need the size of the tooltip, we’ll create a remember state of
IntSize type similar to
componentSize
and update it on onSizeChanged callback on the
ArrowTooltip
modifier.
var tooltipSize by remember { mutableStateOf(IntSize(0, 0)) }
ArrowTooltip( modifier = Modifier .widthIn(max = maxWidth) .onSizeChanged { tooltipSize = it } .padding(paddingValues), title = title, text = text, arrowAlignment = arrowAlignment, textAlign = textAlign )
So finally to the point, to calculate the position or offset of the tooltip we use the combination of derivedStateOf and
remember to optimize the calculations on recomposition.
val tooltipOffset by remember(positionInRoot, componentSize, tooltipSize) { derivedStateOf { calculateOffset( positionInRoot, componentSize, tooltipSize, screenWidthPx, screenHeightPx, horizontalAlignment, verticalAlignment ) } }
private fun calculateOffset( positionInRoot: IntOffset, componentSize: IntSize, tooltipSize: IntSize, screenWidthPx: Int, screenHeightPx: Int, horizontalAlignment: Alignment.Horizontal, verticalAlignment: Alignment.Vertical ): IntOffset { val horizontalAlignmentPosition = when (horizontalAlignment) { Alignment.Start -> positionInRoot.x Alignment.End -> positionInRoot.x + componentSize.width - tooltipSize.width else -> positionInRoot.x + (componentSize.width / 2) - (tooltipSize.width / 2) } val verticalAlignmentPosition = when (verticalAlignment) { Alignment.Top -> positionInRoot.y - tooltipSize.height Alignment.Bottom -> positionInRoot.y + componentSize.height else -> positionInRoot.y + (componentSize.height / 2) } val reult = IntOffset( x = min(screenWidthPx - tooltipSize.width, horizontalAlignmentPosition), y = min(screenHeightPx - tooltipSize.height, verticalAlignmentPosition) ) return reult }
Now finally we need to set the ArrowTooltip
modifier with tooltipOffset
, have a look:
ArrowTooltip( modifier = Modifier .widthIn(max = maxWidth) .onSizeChanged { tooltipSize = it } .offset { tooltipOffset } .padding(paddingValues), ... )
Finally, have a look at the code when we bring all the pieces together:
@Composable | |
fun Modifier.tooltip( | |
title: String? = null, | |
text: String, | |
arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, | |
textAlign: TextAlign = TextAlign.Center, | |
maxWidth: Dp = Dp.Unspecified, | |
enabled: Boolean = true, | |
paddingValues: PaddingValues = PaddingValues(), | |
showOverlay: Boolean = true, | |
highlightComponent: Boolean = true, | |
horizontalAlignment: Alignment.Horizontal = Alignment.Start, | |
verticalAlignment: Alignment.Vertical = Alignment.Top, | |
onDismiss: () -> Unit = {} | |
): Modifier { | |
val configuration = LocalConfiguration.current | |
val density = LocalDensity.current | |
val screenWidthPx = remember { with(density) { configuration.screenWidthDp.dp.roundToPx() } } | |
val screenHeightPx = remember { with(density) { configuration.screenHeightDp.dp.roundToPx() } } | |
var positionInRoot by remember { mutableStateOf(IntOffset.Zero) } | |
var tooltipSize by remember { mutableStateOf(IntSize(0, 0)) } | |
var componentSize by remember { mutableStateOf(IntSize(0, 0)) } | |
val tooltipOffset by remember(positionInRoot, componentSize, tooltipSize) { | |
derivedStateOf { | |
calculateOffset( | |
positionInRoot, componentSize, tooltipSize, screenWidthPx, screenHeightPx, horizontalAlignment, verticalAlignment | |
) | |
} | |
} | |
if (enabled) { | |
Popup( | |
alignment = Alignment.TopEnd | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.drawOverlayBackground( | |
showOverlay = showOverlay, | |
highlightComponent = highlightComponent, | |
positionInRoot = positionInRoot, | |
componentSize = componentSize, | |
backgroundColor = MaterialTheme.colorScheme.surfaceContainerLowest, | |
backgroundAlpha = 0.8f | |
) | |
.clickable( | |
onClick = { | |
onDismiss() | |
} | |
) | |
) { | |
ArrowTooltip( | |
modifier = Modifier | |
.widthIn(max = maxWidth) | |
.onSizeChanged { tooltipSize = it } | |
.offset { tooltipOffset } | |
.padding(paddingValues), | |
title = title, | |
text = text, | |
arrowAlignment = arrowAlignment, | |
textAlign = textAlign | |
) | |
} | |
} | |
} | |
return this then Modifier.onPlaced { | |
componentSize = it.size | |
positionInRoot = it.positionInRoot().round() | |
} | |
} | |
@Composable | |
fun ArrowTooltip( | |
modifier: Modifier = Modifier, | |
title: String? = null, | |
text: String, | |
arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, | |
textAlign: TextAlign = TextAlign.Center, | |
) { | |
Column( | |
modifier = modifier, | |
horizontalAlignment = arrowAlignment, | |
) { | |
Icon( | |
modifier = Modifier | |
.padding( | |
start = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp, | |
end = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp | |
), | |
painter = painterResource(id = R.drawable.ic_arrow_up), | |
contentDescription = "Tool tip Arrow", | |
tint = MaterialTheme.colorScheme.surfaceContainer, | |
) | |
Column( | |
modifier = Modifier | |
.clip(RoundedCornerShape(8.dp)) | |
.background(MaterialTheme.colorScheme.surfaceContainer) | |
.padding(16.dp), | |
) { | |
Text( | |
modifier = Modifier | |
.fillMaxWidth(), | |
text = title ?: "", | |
color = MaterialTheme.colorScheme.primary, | |
textAlign = textAlign, | |
style = MaterialTheme.typography.bodyMedium.copy( | |
fontWeight = FontWeight.Medium | |
) | |
) | |
Spacer(modifier = Modifier.height(4.dp)) | |
Text( | |
modifier = Modifier | |
.fillMaxWidth(), | |
text = text, | |
textAlign = textAlign, | |
style = MaterialTheme.typography.bodySmall | |
) | |
} | |
} | |
} | |
private fun calculateOffset( | |
positionInRoot: IntOffset, | |
componentSize: IntSize, | |
tooltipSize: IntSize, | |
screenWidthPx: Int, | |
screenHeightPx: Int, | |
horizontalAlignment: Alignment.Horizontal, | |
verticalAlignment: Alignment.Vertical | |
): IntOffset { | |
val horizontalAlignmentPosition = when (horizontalAlignment) { | |
Alignment.Start -> positionInRoot.x | |
Alignment.End -> positionInRoot.x + componentSize.width - tooltipSize.width | |
else -> positionInRoot.x + (componentSize.width / 2) - (tooltipSize.width / 2) | |
} | |
val verticalAlignmentPosition = when (verticalAlignment) { | |
Alignment.Top -> positionInRoot.y - tooltipSize.height | |
Alignment.Bottom -> positionInRoot.y + componentSize.height | |
else -> positionInRoot.y + (componentSize.height / 2) | |
} | |
val reult = IntOffset( | |
x = min(screenWidthPx - tooltipSize.width, horizontalAlignmentPosition), | |
y = min(screenHeightPx - tooltipSize.height, verticalAlignmentPosition) | |
) | |
return reult | |
} | |
private fun Modifier.drawOverlayBackground( | |
showOverlay: Boolean, | |
highlightComponent: Boolean, | |
positionInRoot: IntOffset, | |
componentSize: IntSize, | |
backgroundColor: Color, | |
backgroundAlpha: Float | |
) : Modifier { | |
return if (showOverlay) { | |
if (highlightComponent) { | |
drawBehind { | |
val highlightPath = Path().apply { | |
addRect(Rect(positionInRoot.toOffset(), componentSize.toSize())) | |
} | |
clipPath(highlightPath, clipOp = ClipOp.Difference) { | |
drawRect(SolidColor(backgroundColor.copy(alpha = backgroundAlpha))) | |
} | |
} | |
} else { | |
background(backgroundColor.copy(alpha = backgroundAlpha)) | |
} | |
} else { | |
this | |
} | |
} | |
Job Offers
Invoke the Tooltip From Feature Composable
The moment of the truth, we are done with the tool design, it’s time to use it in the feature and see the output. To keep it simple, let’s have text at the center of the screen and align the tooltip to it. Have a look:
@Composable fun previewtooltips() { Scaffold { Box( modifier = Modifier .padding(it) .fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( modifier = Modifier .tooltips( title = "This is the Title", text = "This is the description", textAlign = TextAlign.Center, enabled = true, maxWidth = 180.dp, arrowAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.Bottom, horizontalAlignment = Alignment.CenterHorizontally ), text = "This is the sample text in preview" ) } } }

Conclusion
We’ve created the core logic of designing a tooltip without using any third-party libraries. Of course, there is room for development to make it production-ready. But with the logic and knowledge we acquired here, I’m sure we’re more than ready to optimize according to the requirements.
Reference:
That is all for now. I hope you learned something useful. Thanks for reading!
This article was previously published on proandroiddev.com.