tutorial-view
0.3.0indexedSpotlight 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: 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().
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", , ),
)
)
TutorialView(state = state) {
Scaffold(...) {
Button(onClick = { ... }, modifier = Modifier.tutorialTarget()) { Text() }
LazyColumn(Modifier.tutorialTarget()) { ... }
Avatar(Modifier.tutorialTarget())
}
}
LaunchedEffect() { (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")) { ... }
TutorialView ships with four presets and a fully customizable TutorialAnimations config object.
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.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.
| Platform | Supported | Tested |
|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
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.| 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 | ❌ |
| TutorialView | Hand-rolled overlay | Android TapTargetView |
|---|
| Multiplatform | ✅ A/iOS/Desktop/Web | ⚠️ you build 4 of them | ❌ Android only |
| Spotlight cutout | ✅ rounded-rect, configurable | ⚠️ DIY path math | ✅ circular |
| Step-through controls | ✅ Back / Skip / Next built-in | ❌ DIY | ⚠️ chained callbacks |
| Live target tracking | ✅ | ⚠️ DIY layout coords | ⚠️ partial |
| Lifecycle-safe targets | ✅ auto-unregister | ⚠️ DIY | n/a (Views) |
| Pointer & tap blocking | ✅ scrim consumes taps | ⚠️ DIY | ✅ |
Surfaced from shared tags and platforms — no rankings paid for.