a spotlight tour in jetpack compose, on a driver signup screen
a dark screen with one bright hole in it, pointing at the button a new driver can't find. it's about 120 lines of compose, and most of them are easy.
by shritupdated 9 min read

tl;dr (too long, didn't read)
a compose spotlight tour is three pieces: a modifier that records each target's bounds with onGloballyPositioned and boundsInRoot(), an overlay canvas that draws a dim scrim and clears a rounded hole with BlendMode.Clear, and a tooltip placed above or below the hole.
wrap the steps in a tiny state holder (current index, next, skip) and put the overlay as the last child of your root Box, so its coordinates match the targets'.
the code is the easy part. keeping the copy right in every language your drivers speak, and the targets right across releases, is where it gets expensive. know that before you build twenty of these.
what we're building
picture the signup screen in a delivery partner app. a new driver has typed their phone number and name, and now there's a document section, a vehicle type picker and a continue button that stays grey until both are done. this is the kind of screen where a new driver stalls. maybe they don't know which document counts, or they never notice the vehicle picker below the fold.
a spotlight tour is the classic fix. the screen dims, one control stays bright, and a small card next to it says what to do. tap next and the bright spot moves on. older android projects used libraries like TapTargetView or MaterialShowcaseView for this. in compose you mostly don't need a library, because compose hands you every piece directly: the size and position of any composable, a canvas you can draw anything on, and state that recomposes the overlay when a step changes.
we'll build it in four parts. first a way to mark targets and remember where they are. then the dimmed overlay with a hole cut out of it. then the tooltip. then the bit of state that walks through the steps, wired into a driver signup screen. at the end there are notes on accessibility and the edge cases that bite in real partner apps.
step 1: remember where each target is
the overlay needs to know the exact rectangle of each element it points at. compose will tell you that through onGloballyPositioned, which runs after layout with the element's coordinates. boundsInRoot() turns those into a Rect relative to the root of the compose hierarchy, which is the coordinate space our overlay will draw in.
so we keep a map from a step key to a rectangle, and a modifier that fills it in:
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
data class TourStep(val targetKey: String, val text: String)
class TourState(private val steps: List<TourStep>) {
val targets = mutableStateMapOf<String, Rect>()
var index by mutableIntStateOf(0)
private set
val current: TourStep? get() = steps.getOrNull(index)
val isLast: Boolean get() = index == steps.lastIndex
fun next() { index++ }
fun skip() { index = steps.size }
}
@Composable
fun rememberTourState(steps: List<TourStep>): TourState =
remember { TourState(steps) }
fun Modifier.tourTarget(state: TourState, key: String): Modifier =
onGloballyPositioned { coordinates ->
state.targets[key] = coordinates.boundsInRoot()
}because targets is a snapshot state map, the overlay recomposes whenever a target moves, for example when the keyboard pushes the signup form up after the driver types their name. onGloballyPositioned can fire often, but writing the same rectangle again is cheap, so we don't bother deduplicating.
step 2: the scrim with a hole in it
this is the part that looks hard and isn't. we draw a translucent black rectangle over the whole screen, then draw a rounded rectangle over the target with BlendMode.Clear, which erases whatever is underneath it.
there's one catch. clear erases pixels in the layer it draws into, and by default a composable draws straight into the window, so clearing would punch a hole through your whole app and show black. putting the canvas on its own offscreen layer with CompositingStrategy.Offscreen keeps the erasing contained to the scrim.
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
@Composable
private fun Scrim(hole: Rect, modifier: Modifier = Modifier) {
Canvas(
modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
) {
drawRect(Color.Black.copy(alpha = 0.62f))
drawRoundRect(
color = Color.Transparent,
topLeft = hole.topLeft,
size = hole.size,
cornerRadius = CornerRadius(12.dp.toPx()),
blendMode = BlendMode.Clear,
)
}
}if you'd rather avoid blend modes, a Path with fillType = PathFillType.EvenOdd containing both the full-screen rect and the rounded hole works too. the blend mode version is shorter and easier to animate later.
step 3: the tooltip
the tooltip goes below the target when the target is in the top half of the screen, and above it otherwise. to place it above, we need its height, which we only know after it's measured, so we capture it with onSizeChanged and use it inside an offset lambda. the lambda version of offset runs during placement, so changing it doesn't trigger recomposition.
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt
@Composable
fun TourOverlay(state: TourState) {
val step = state.current ?: return
val target = state.targets[step.targetKey] ?: return
val density = LocalDensity.current
val hole = target.inflate(with(density) { 8.dp.toPx() })
val gap = with(density) { 12.dp.toPx() }
var tipHeight by remember { mutableIntStateOf(0) }
BoxWithConstraints(
Modifier
.fillMaxSize()
// swallow taps so the app underneath can't be used mid-tour
.pointerInput(Unit) { detectTapGestures { } }
) {
val below = hole.center.y < constraints.maxHeight / 2f
Scrim(hole, Modifier.matchParentSize())
Card(
Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.onSizeChanged { tipHeight = it.height }
.offset {
val y = if (below) hole.bottom + gap else hole.top - gap - tipHeight
IntOffset(0, y.roundToInt())
}
.semantics { liveRegion = LiveRegionMode.Polite }
) {
Column(Modifier.padding(16.dp)) {
Text(step.text, style = MaterialTheme.typography.bodyLarge)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = state::skip) { Text("Skip") }
Button(onClick = state::next) { Text(if (state.isLast) "Done" else "Next") }
}
}
}
}
}note the empty tap handler on the outer box. it blocks touches from reaching the screen underneath while the tour is up. whether you want that is a product decision. some teams let a tap on the highlighted control itself advance the tour, which feels nicer, and you can do it by checking whether the tap offset falls inside hole before calling next().
step 4: wiring it into a screen
now the driver signup screen. the one rule that matters: the overlay must live in the same compose root as the targets, drawn after them. a Dialog or Popup creates a new window with its own root, and then boundsInRoot() of your targets won't line up with the overlay. the simplest setup is a Box around the screen with the overlay as its last child.
@Composable
fun DriverSignupScreen() {
val tour = rememberTourState(
listOf(
TourStep("licence", "Tap here and take a clear photo of your driving licence."),
TourStep("vehicle", "Now pick the vehicle you will deliver on."),
TourStep("continue", "All done. Tap continue to book your training."),
)
)
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
DocumentUploadCard(
title = "Driving licence",
modifier = Modifier.tourTarget(tour, "licence"),
)
VehicleTypePicker(Modifier.tourTarget(tour, "vehicle"))
Spacer(Modifier.weight(1f))
Button(
onClick = { /* submit signup */ },
modifier = Modifier.fillMaxWidth().tourTarget(tour, "continue"),
) { Text("Continue") }
}
TourOverlay(tour)
}
}run it and you get a dimmed screen with the licence card lit up and a tooltip underneath it. next moves the light to the vehicle picker, then to the continue button at the bottom, where the tooltip flips above the hole because the target sits in the lower half. once the index runs past the last step, current is null and the overlay disappears on its own.
accessibility and the edge cases
a spotlight is a visual idea, so it needs some care to work for everyone. the tooltip above is a polite live region, which makes TalkBack read each step as it appears. keep the skip and next buttons real buttons with visible labels, and don't rely on tap-anywhere-to-continue as the only way forward, since screen reader users can't see where anywhere is. if your design dims the whole screen, make sure the tooltip text still has strong contrast against the card. drivers often read it outdoors, in bright sun, on a dim budget screen.
a few things will catch you in a real app. items in a LazyColumn that aren't on screen aren't composed, so their target never reports a rectangle and the step quietly never shows. scroll the list to the item first with LazyListState.animateScrollToItem, then show the step. this matters more than usual on a signup screen, because drivers leave the app to open the camera for a document photo, and on cheap phones the system often kills your process while they're gone. the simple holder above survives recomposition but not process death, so keep the index in rememberSaveable and rebuild the tour from it. and store somewhere that a driver finished or skipped the tour, or they'll see it every time they come back to finish signup.
the part that doesn't fit in a tutorial
everything above is maybe an afternoon of work, and it's a good afternoon. the trouble starts later. the copy lives in kotlin, so fixing a confusing step means a release, Play Store review, and weeks of drivers on the old version. your drivers don't all read english, so each step needs Hindi and probably two or three regional languages, and every one of those strings is now an engineer's job. the ops team, who see exactly where drivers quit, can't change a word without a ticket.
then the screen changes. someone moves the vehicle picker into its own page, and the tour now spotlights empty space, and nothing tells you. and a tour only fires when you decided it should, which isn't always when the driver got stuck. a text card also assumes the driver reads it. plenty of new drivers would rather be told what to do, out loud, in their own language.
teams do build this themselves. Shadowfax built an in-house tool called Frodo, and Porter added regional language audio. if you'd rather not, that's the problem heytappr exists for. it's an android SDK you add as a Gradle dependency and initialise in Application.onCreate. it shows a new driver where to tap and talks them through it, Hindi first with regional languages after, until they finish their first order. guides are edited from a dashboard and go live without an app release, and if a release moves a button, heytappr flags the broken step. if you've just built the tutorial version, you already know what it does under the hood. the difference is who maintains it.
see it in your product
fifteen minutes: heytappr answering a real question by walking a user through a real interface, out loud.
questions people ask
How do I get the position of a composable in Jetpack Compose?
Use Modifier.onGloballyPositioned and call boundsInRoot() on the coordinates it gives you. That returns a Rect relative to the root of the compose hierarchy, which you can store in state and use from an overlay.
How do I cut a hole in an overlay in Compose?
Draw the scrim on a Canvas with graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen), then draw a shape over the target with BlendMode.Clear. Alternatively, draw a Path with PathFillType.EvenOdd containing both the full rectangle and the hole.
Is there a showcase view library for Jetpack Compose?
There are community libraries, but a spotlight tour is small enough to build directly with onGloballyPositioned, a Canvas and a bit of state, which also gives you full control over styling and accessibility.
Why doesn't my spotlight line up with the target?
Usually because the overlay is in a Dialog or Popup, which has its own root. Put the overlay as the last child of a Box in the same hierarchy as the targets so boundsInRoot() shares a coordinate space.
Why does my onboarding tour restart after a driver takes a document photo?
Opening the camera can get your app's process killed on low-memory phones. Keep the tour's step index in rememberSaveable, and persist whether the driver finished or skipped the tour.
go deeper
keep reading
- why your onboarding overlay broke on tuesday
- how to stop the "how do i" calls from new drivers
- marketplace onboarding on the supply side, from download to first order
- why new delivery drivers quit before their first order
- in-app guidance on android, for the apps drivers use
spotted something out of date? tell us and it gets fixed.