
Spotlight onboarding tours: dim screen with rounded cutouts, anchor explanatory tooltips with Back/Skip/Next, live target tracking, lifecycle-safe targets, customizable shapes, animations and labels.
Spotlight onboarding tours for Compose Multiplatform — dim the screen, cut out a rounded spotlight around the UI element you want to introduce, and surface an explanatory tooltip with Back / Skip / Next controls. One module, every CMP target.
Every product app eventually needs a first-run tour. The pattern is well-known — dim the screen,
spotlight one UI element, explain it, Next, repeat — but no notable CMP library ships it. Teams
rebuild it from scratch each time, and the cutout/anchor/lifecycle math is annoying enough that
the result usually feels half-baked. TutorialView is the polished primitive: declare your steps,
mark your targets, call start().
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
tutorial-view = { module = "io.github.nadeemiqbal:tutorial-view", version = "0.2.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.tutorial.view)
}
}
}Three pieces: mark targets, declare steps, host them.
val state = rememberTutorialState(
steps = listOf(
TutorialStep("compose", "Compose", "Tap here to write a new message"),
TutorialStep("inbox", "Inbox", "Your messages live here"),
TutorialStep("profile", "Profile", "Open your account and settings"),
)
)
TutorialView(state = state) {
Scaffold(...) {
Button(onClick = { ... }, modifier = Modifier.tutorialTarget("compose")) { Text("Compose") }
LazyColumn(Modifier.tutorialTarget("inbox")) { ... }
Avatar(Modifier.tutorialTarget("profile"))
}
}
// Start the tour whenever you like — e.g. on first run.
LaunchedEffect(Unit) { if (firstRun) state.start() }Programmatic navigation
state.start() // begin the tour at step 0
state.next() // advance (or finish, on the last step)
state.previous() // back one step
state.skip() // dismiss without finishing
state.finish() // explicitly end
state.isActive // Boolean — overlay visible?
state.currentStepIndex // Int
state.currentStep // TutorialStep? (null when inactive)Custom button labels (localization)
TutorialView(
state = state,
nextLabel = "Continue",
finishLabel = "Got it",
previousLabel = "Back",
skipLabel = "Maybe later",
) { ... }Per-step cutout shape
TutorialStep(
targetKey = "fab",
title = "Quick action",
description = "Tap to add a new entry",
cornerRadius = 32.dp, // circle-ish cutout for the FAB
padding = 12.dp, // extra breathing room
)Custom scrim colour
TutorialView(
state = state,
scrimColor = Color.Black.copy(alpha = 0.85f),
) { ... }Pick or build a transition animation
// One of the curated presets — Default, Subtle, Bouncy, None.
TutorialView(state = state, animations = TutorialAnimations.Bouncy) { ... }
// Or tweak a preset in one line:
TutorialView(
state = state,
animations = TutorialAnimations.Default.copy(
spotlightSpec = spring(dampingRatio = 0.4f, stiffness = 200f),
pulseEnabled = false,
),
) { ... }Targets safely no-op outside the tour
// Modifier.tutorialTarget is a no-op when no TutorialView is hosting the tree.
// Leave the keys on production UI; the runtime cost is zero outside a tour.
Button(Modifier.tutorialTarget("compose")) { ... }scrimColor — the dim wash. Use higher alpha for more focus, lower for a hint of the
underlying UI.tooltipShape / tooltipMargin — visual chrome of the tooltip card.nextLabel / finishLabel / previousLabel / skipLabel — button text for i18n.onFinish callback — fires once when the tour ends (Done or Skip). Use it to persist
"first-run done" so the tour doesn't replay.TutorialStep.cornerRadius / padding — per-step cutout shape; mix and match between steps.animations — transitions between steps + on start/dismiss. Pass TutorialAnimations.Default
/ Subtle / Bouncy / None, or build your own. See below.TutorialView ships with four presets and a fully customizable TutorialAnimations config object.
| Preset | Spotlight | Tooltip | Pulse |
|---|---|---|---|
Default |
tween(380) — smooth slide |
horizontal slide + fade | ✅ |
Subtle |
tween(260) — quicker, no slide |
pure fade | ❌ |
Bouncy |
spring(damp=0.55) — playful overshoot |
scale-in + fade | ✅ |
None |
snap | none | ❌ |
The clip above (iPhone 17 sim) cycles through every preset plus a custom override.
Reproduce per-preset GIFs locally with ./scripts/record-animations.sh.
Every field on TutorialAnimations is a standard Compose animation type, so anything you'd
write inline with fadeIn() + slideInVertically() slots straight in:
val mine = TutorialAnimations(
overlayEnter = fadeIn(tween(220)),
overlayExit = fadeOut(tween(180)),
spotlightSpec = spring(dampingRatio = 0.5f, stiffness = 240f),
tooltipForwardEnter = slideInHorizontally { it } + fadeIn(),
tooltipForwardExit = slideOutHorizontally { -it } + fadeOut(),
tooltipBackEnter = slideInHorizontally { -it } + fadeIn(),
tooltipBackExit = slideOutHorizontally { it } + fadeOut(),
pulseEnabled = true,
pulseAmplitudeDp = 3.dp,
pulseDurationMillis = 1200,
)
TutorialView(state = state, animations = mine) { ... }For most cases, .copy() on a preset is the shortest path:
animations = TutorialAnimations.Default.copy(pulseEnabled = false)The tooltip is anchored at the top or bottom of the screen, picked automatically so it stays clear of the highlighted element: target in the upper half of the screen → tooltip at the bottom, and vice-versa. There's no "speech bubble" pointer in v0.1.0 — the dim scrim + glowing cutout already direct attention to the target.
onGloballyPositioned. If your UI animates, the cutout
follows — no manual updates needed.tutorialTarget("foo") on a since-removed screen can't anchor the cutout in the wrong place.Next past it cleanly.| TutorialView | Hand-rolled overlay | Android TapTargetView
|
|
|---|---|---|---|
| Multiplatform | ✅ A/iOS/Desktop/Web | ❌ Android only | |
| Spotlight cutout | ✅ rounded-rect, configurable | ✅ circular | |
| Step-through controls | ✅ Back / Skip / Next built-in | ❌ DIY | |
| Live target tracking | ✅ | ||
| Lifecycle-safe targets | ✅ auto-unregister | n/a (Views) | |
| Pointer & tap blocking | ✅ scrim consumes taps | ✅ |
content: @Composable () -> Unit)See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.
Spotlight onboarding tours for Compose Multiplatform — dim the screen, cut out a rounded spotlight around the UI element you want to introduce, and surface an explanatory tooltip with Back / Skip / Next controls. One module, every CMP target.
Every product app eventually needs a first-run tour. The pattern is well-known — dim the screen,
spotlight one UI element, explain it, Next, repeat — but no notable CMP library ships it. Teams
rebuild it from scratch each time, and the cutout/anchor/lifecycle math is annoying enough that
the result usually feels half-baked. TutorialView is the polished primitive: declare your steps,
mark your targets, call start().
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
tutorial-view = { module = "io.github.nadeemiqbal:tutorial-view", version = "0.2.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.tutorial.view)
}
}
}Three pieces: mark targets, declare steps, host them.
val state = rememberTutorialState(
steps = listOf(
TutorialStep("compose", "Compose", "Tap here to write a new message"),
TutorialStep("inbox", "Inbox", "Your messages live here"),
TutorialStep("profile", "Profile", "Open your account and settings"),
)
)
TutorialView(state = state) {
Scaffold(...) {
Button(onClick = { ... }, modifier = Modifier.tutorialTarget("compose")) { Text("Compose") }
LazyColumn(Modifier.tutorialTarget("inbox")) { ... }
Avatar(Modifier.tutorialTarget("profile"))
}
}
// Start the tour whenever you like — e.g. on first run.
LaunchedEffect(Unit) { if (firstRun) state.start() }Programmatic navigation
state.start() // begin the tour at step 0
state.next() // advance (or finish, on the last step)
state.previous() // back one step
state.skip() // dismiss without finishing
state.finish() // explicitly end
state.isActive // Boolean — overlay visible?
state.currentStepIndex // Int
state.currentStep // TutorialStep? (null when inactive)Custom button labels (localization)
TutorialView(
state = state,
nextLabel = "Continue",
finishLabel = "Got it",
previousLabel = "Back",
skipLabel = "Maybe later",
) { ... }Per-step cutout shape
TutorialStep(
targetKey = "fab",
title = "Quick action",
description = "Tap to add a new entry",
cornerRadius = 32.dp, // circle-ish cutout for the FAB
padding = 12.dp, // extra breathing room
)Custom scrim colour
TutorialView(
state = state,
scrimColor = Color.Black.copy(alpha = 0.85f),
) { ... }Pick or build a transition animation
// One of the curated presets — Default, Subtle, Bouncy, None.
TutorialView(state = state, animations = TutorialAnimations.Bouncy) { ... }
// Or tweak a preset in one line:
TutorialView(
state = state,
animations = TutorialAnimations.Default.copy(
spotlightSpec = spring(dampingRatio = 0.4f, stiffness = 200f),
pulseEnabled = false,
),
) { ... }Targets safely no-op outside the tour
// Modifier.tutorialTarget is a no-op when no TutorialView is hosting the tree.
// Leave the keys on production UI; the runtime cost is zero outside a tour.
Button(Modifier.tutorialTarget("compose")) { ... }scrimColor — the dim wash. Use higher alpha for more focus, lower for a hint of the
underlying UI.tooltipShape / tooltipMargin — visual chrome of the tooltip card.nextLabel / finishLabel / previousLabel / skipLabel — button text for i18n.onFinish callback — fires once when the tour ends (Done or Skip). Use it to persist
"first-run done" so the tour doesn't replay.TutorialStep.cornerRadius / padding — per-step cutout shape; mix and match between steps.animations — transitions between steps + on start/dismiss. Pass TutorialAnimations.Default
/ Subtle / Bouncy / None, or build your own. See below.TutorialView ships with four presets and a fully customizable TutorialAnimations config object.
| Preset | Spotlight | Tooltip | Pulse |
|---|---|---|---|
Default |
tween(380) — smooth slide |
horizontal slide + fade | ✅ |
Subtle |
tween(260) — quicker, no slide |
pure fade | ❌ |
Bouncy |
spring(damp=0.55) — playful overshoot |
scale-in + fade | ✅ |
None |
snap | none | ❌ |
The clip above (iPhone 17 sim) cycles through every preset plus a custom override.
Reproduce per-preset GIFs locally with ./scripts/record-animations.sh.
Every field on TutorialAnimations is a standard Compose animation type, so anything you'd
write inline with fadeIn() + slideInVertically() slots straight in:
val mine = TutorialAnimations(
overlayEnter = fadeIn(tween(220)),
overlayExit = fadeOut(tween(180)),
spotlightSpec = spring(dampingRatio = 0.5f, stiffness = 240f),
tooltipForwardEnter = slideInHorizontally { it } + fadeIn(),
tooltipForwardExit = slideOutHorizontally { -it } + fadeOut(),
tooltipBackEnter = slideInHorizontally { -it } + fadeIn(),
tooltipBackExit = slideOutHorizontally { it } + fadeOut(),
pulseEnabled = true,
pulseAmplitudeDp = 3.dp,
pulseDurationMillis = 1200,
)
TutorialView(state = state, animations = mine) { ... }For most cases, .copy() on a preset is the shortest path:
animations = TutorialAnimations.Default.copy(pulseEnabled = false)The tooltip is anchored at the top or bottom of the screen, picked automatically so it stays clear of the highlighted element: target in the upper half of the screen → tooltip at the bottom, and vice-versa. There's no "speech bubble" pointer in v0.1.0 — the dim scrim + glowing cutout already direct attention to the target.
onGloballyPositioned. If your UI animates, the cutout
follows — no manual updates needed.tutorialTarget("foo") on a since-removed screen can't anchor the cutout in the wrong place.Next past it cleanly.| TutorialView | Hand-rolled overlay | Android TapTargetView
|
|
|---|---|---|---|
| Multiplatform | ✅ A/iOS/Desktop/Web | ❌ Android only | |
| Spotlight cutout | ✅ rounded-rect, configurable | ✅ circular | |
| Step-through controls | ✅ Back / Skip / Next built-in | ❌ DIY | |
| Live target tracking | ✅ | ||
| Lifecycle-safe targets | ✅ auto-unregister | n/a (Views) | |
| Pointer & tap blocking | ✅ scrim consumes taps | ✅ |
content: @Composable () -> Unit)See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.