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.onFinishcallback — 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. PassTutorialAnimations.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.
- Target bounds are tracked live via
onGloballyPositioned. If your UI animates, the cutout follows — no manual updates needed. - Targets are automatically unregistered when their composable leaves composition, so a stale
tutorialTarget("foo")on a since-removed screen can't anchor the cutout in the wrong place. - If the current step's target doesn't exist (yet), the scrim renders full-dim with no cutout
and the tooltip still appears — the user can
Nextpast 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 | ✅ |
- Speech-bubble pointer from the tooltip to the target
- Built-in "show once" persistence helper (multiplatform storage)
- Per-step custom tooltip content slot (
content: @Composable () -> Unit) - Reduced-motion / accessibility flag that respects the OS setting per target
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.
