keyspot
Interactive onboarding and guided tours for Flutter.
Go beyond static coach marks: highlight a widget, show the gesture it needs, and walk users through your app step by step.
- Why keyspot?
- At a glance
- See keyspot in action
- Get started
- Use cases
- Installation
- Quick start
- Spotlights
- The pointer
- Tours
- The tracker, on its own
- Theming
- Accessibility
- Hardening guarantees
- Testing apps that use keyspot
- Running the demos
- Platforms
Why keyspot?
Coach marks, product tours, feature discovery, in-app tutorials, showcase overlays — whatever you call them, most packages can tell a user where something is. Keyspot also shows them how to use it, and keeps up when the screen moves.
- Drift-free tracking. The spotlight and pointer re-resolve the target's geometry every frame, so they follow it through scrolling, keyboard insets, rotation and window resizing — instead of painting a stale snapshot.
- Gesture choreography. An animated hand that glides between targets, rotates, and pulses — teaching gestures, not just locations.
Everything is addressed by GlobalKey — no coordinates, no wrapper widgets.
Zero runtime dependencies; the hand is a CustomPainter, not an asset.
At a glance
| Widget spotlight with live per-frame tracking | ✅ |
| Scroll-aware targets + auto scroll-into-view | ✅ |
| Custom cut-out shapes (auto / circle / rrect / stadium / path) | ✅ |
| Ring stacks with independent colour, width, gap and pulse | ✅ |
| Barrier control, incl. "must tap the real widget" | ✅ |
| Animated hand pointer: glide, arc, rotate, tap-pulse | ✅ |
| Custom pointer artwork (emoji, PNG, GIF, SVG, Lottie, Rive) | ✅ |
| Multi-step guided tours with content cards + persistence hook | ✅ |
| Reduced-motion, semantics and RTL support | ✅ |
| Runtime dependencies | none |
| Platforms | all 6 |
Multi-touch choreography such as pinch is composed from sequential pointer
moves rather than being a dedicated API — see the gesture_teaching demo for
the pattern.
See keyspot in action
From custom spotlights to gesture guidance and complete multi-step tours, keyspot gives you the tools to build interactive onboarding experiences. Every clip below is a page in the example app.
🎯 Custom spotlight shapes — layered rings and flexible cut-out styling.
👆 Animated hand pointer — guide users with fully customisable movement and timing.
🤏 Gesture teaching — demonstrate interactions like pinch and drag, not just where to tap.
🧭 Multi-step guided tours — progress tracking, skip, back and step-by-step navigation.
🎨 Customisation — customise spotlight rings, barriers, pointers and artwork to match your app.
Get started
flutter pub add keyspot
Then jump to the quick start — one wrapper widget and one call is the whole integration.
Use cases
| Use case | What you reach for | Demo |
|---|---|---|
| First-run user onboarding | KeyspotTour + step content cards |
full_tour |
| Teach a gesture ("drag this here") | pointer.moveTo with an arc path |
gesture_teaching |
| Teach pinch/rotate on an image | spotlight + overlayBuilder + pointer |
gesture_teaching |
| Highlight a row in a scrollable list | scrollIntoView + live tracking |
scrolling_list |
| Feature discovery after an update | one-off spotlight, dismissOnTap() |
basic_spotlight |
| Narrated step that holds while audio plays | spotlight(until: playNarration) |
basic_spotlight |
| "You must actually tap this" | SpotBarrier.targetOnly() |
basic_spotlight, full_tour |
| Desktop/web product tour with a mouse | custom cursor via PointerStyle.builder |
theming |
Installation
flutter pub add keyspot
Or add it manually:
dependencies:
keyspot: ^0.3.1
Requires Dart ≥ 3.4 and Flutter ≥ 3.22.
Quick start
final keyspot = KeyspotController();
final composeKey = GlobalKey();
// One wrapper, anywhere above your content.
KeyspotScope(
controller: keyspot,
child: MaterialApp(home: Inbox(composeKey: composeKey)),
);
// Highlight a widget for two seconds.
await keyspot.spotlight(composeKey);
KeyspotScope mounts the overlays itself — you never place them by hand. Put
it above MaterialApp to cover every route, or inside MaterialApp.builder to
inherit Theme/Directionality for tour content cards. Descendants can fetch
the controller with KeyspotScope.of(context). Its lifetime is yours — call
keyspot.dispose() when done.
Spotlights
Dim the screen and cut a hole around any widget. The cut-out mirrors the target's own rounding by default, and re-measures every frame so it never drifts.
await keyspot.spotlight(
composeKey,
shape: const SpotShape.auto(), // mirrors the widget's own rounding
padding: const EdgeInsets.all(12),
barrier: const SpotBarrier.block(), // absorb stray taps (default)
duration: const Duration(seconds: 4),
semanticLabel: 'Compose button',
);
Calling spotlight() while one is active cancels the old one first — never
two at once, and the old future completes as cancelled rather than hanging.
Shapes — auto() (default) mirrors the target's own clip/decoration where
it can, else a circle for square-ish widgets and a 12px rrect otherwise:
const SpotShape.circle();
const SpotShape.rrect(radius: 28);
const SpotShape.stadium();
SpotShape.path((Rect rect) => Path()..addOval(rect)); // any outline
Rings — stacked outlines, each with its own colour, width, gap and opacity pulse, set per call, per tour step, or once in the theme:
rings: const <RingStyle>[
RingStyle(color: Color(0xFFFFB300), width: 6), // warm rim
RingStyle(
color: Color(0xFF00E676),
width: 4,
pulse: RingPulse(minOpacity: 0.3, period: Duration(milliseconds: 350)),
),
],
rings: const <RingStyle>[] // no rings
// rings: null (default) // use the theme's rings
Barriers — the capability most packages skip:
| Barrier | Outside the cut-out | Inside the cut-out |
|---|---|---|
passthrough() |
passes through | passes through |
block() (default) |
absorbed | absorbed |
dismissOnTap() |
dismisses | absorbed |
targetOnly() |
absorbed | reaches the real widget, then resolves |
targetOnly builds "you must actually tap this" walkthroughs — the button's
own onPressed still fires.
Outcomes — the future tells you why it ended:
final outcome = await keyspot.spotlight(key, barrier: const SpotBarrier.dismissOnTap());
// SpotlightOutcome.finished | dismissedByUser | targetTapped | cancelled
Duration — a timer (duration:), action-scoped (until: () => task(),
resolves even if the callback throws), or programmatic (hideSpotlight()).
Scroll-into-view — on by default (scrollIntoView: true); scrolls the
target to center before measuring. Needs the target to already be built, so
a ListView.builder far below the fold won't resolve — scroll near it first,
or use a non-lazy list for tour targets.
The pointer
An animated hand that shows the interaction, not just the location. It glides along a line or an arc, rotates, and pulses on tap — and every future resolves on real animation completion, never a guessed delay.
await keyspot.pointer.show(cardKey.anchor());
await keyspot.pointer.moveTo(
archiveKey.anchor(),
duration: const Duration(milliseconds: 900),
path: const MotionPath.arc(height: 0.3), // bows, reads as a drag
rotation: const Rotation.degrees(-15),
);
await keyspot.pointer.tapPulse(count: 2);
await keyspot.pointer.hide();
// One-liner: show at A, glide to B, dwell, hide.
await keyspot.pointer.sweep(cardKey.anchor(), archiveKey.anchor());
Every future completes on real AnimationController completion, never a
guessed delay. show() resolves as soon as it's mounted, so a moveTo can
chain right away. keyspot.pointer.phase reports idle → moving → arrived.
Anchors — a point on, or relative to, a widget:
cardKey.anchor() // center
cardKey.anchor(Alignment.bottomCenter) // any Alignment
const Anchor.offset(Offset(120, 480)) // fixed screen point, escape hatch
Rotation is explicit — no degrees/radians guessing — and always animates from the current angle along the shortest path:
const Rotation.degrees(-95);
const Rotation.radians(math.pi / 4);
Custom artwork — PointerStyle.builder is a plain WidgetBuilder, so
any widget can be the pointer: emoji, PNG, GIF, SVG, Lottie, Rive.
PointerStyle(
builder: (context) => const FittedBox(child: Text('👆')),
size: 48,
hotspot: Alignment.topCenter, // which point of your artwork sits on the anchor
)
hotspot is the one that matters — rotation, entry scale and the tap-pulse
all pivot around it, so the tip stays planted through every animation.
Animated artwork plays on its own clock while keyspot moves it; the two don't
interfere. flipForRtl: true mirrors it in RTL locales.
Tours
Chain steps into a walkthrough, each with its own target, styling, content card and advance rule — plus a storage hook so a finished tour never runs twice.
final tour = KeyspotTour(
id: 'first-run',
storage: myPrefsStorage, // implement wasSeen / markSeen
steps: <KeyspotStep>[
KeyspotStep(
id: 'search',
targetKey: searchKey,
advance: StepAdvance.manual,
contentBuilder: (context, rect, session) => Card(
child: TextButton(onPressed: session.next, child: const Text('Next')),
),
),
KeyspotStep(
id: 'compose',
targetKey: composeKey,
advance: StepAdvance.tapTarget, // must actually press it
),
],
);
final result = await keyspot.startTour(tour);
// TourResult.completed | skipped | cancelled
StepAdvance picks a matching barrier: tapTarget → targetOnly(),
tapAnywhere → dismiss anywhere, manual → waits for session.next(),
after(duration) → timer. Content cards position themselves above or below
the cut-out from the live rect. If the tour's storage reports it seen,
startTour returns skipped without showing anything — ships
InMemoryTourStorage; plug in shared_preferences via the two-method
KeyspotTourStorage interface.
The tracker, on its own
The piece that stops the drift is public — useful on its own for badges, connector lines or coach-marks on any widget:
KeyspotAnchorTracker(
targetKey: cartKey,
mode: TrackingMode.everyFrame, // or onScrollAndMetrics, or once
builder: (context, rect) => rect == null
? const SizedBox.shrink()
: Positioned(left: rect.right - 8, top: rect.top - 8, child: const Badge()),
)
Theming
Set the look once, override it per call. Rings, barrier, timings and the pointer itself are all replaceable — the hand can be any widget you like.
MaterialApp(
theme: ThemeData(extensions: const <ThemeExtension<dynamic>>[
KeyspotTheme(
barrierOpacity: 0.8,
rings: <RingStyle>[RingStyle(color: Colors.white, width: 6)],
pointerStyle: PointerStyle(size: 56),
),
]),
)
Resolution order: argument passed → KeyspotScope.theme →
Theme.of(context).extension<KeyspotTheme>() → built-in defaults.
For app-resume narration, keyspot.setResumeHandler((ctx) => ...) fires
whenever the app resumes mid-spotlight or mid-tour.
Accessibility
MediaQuery.disableAnimationsskips glides and ring pulses; the dim and cut-out remain.semanticLabelis announced viaSemanticsService.AlignmentDirectionalanchors andPointerStyle.flipForRtlsupport RTL.
Hardening guarantees
Tested behaviours, not aspirations: an unresolvable target key resolves the
future (cancelled) and paints nothing; a target unmounted mid-spotlight
tears down cleanly; disposing mid-animation completes every pending future
without erroring; any call with no KeyspotScope mounted is an inert no-op; a
throwing until()/onEnter is logged and resolved, never stranding the user.
Testing apps that use keyspot
- Ring pulses repeat forever, so
pumpAndSettlewill time out with a pulsing spotlight up — pump explicit durations, or useRingPulse.none()in tests. - Futures resolve on animation completion — start them, pump the duration,
then assert; don't
awaitbefore pumping. Seetest/harness.dartfor the pattern this package uses.
Running the demos
cd example
flutter run # -d chrome and -d macos are good first picks
| Page | Shows |
|---|---|
basic_spotlight |
every barrier mode, until:, four ring voices |
shapes_gallery |
auto resolution, forced shapes, a custom path |
pointer_playground |
show/moveTo/sweep/tapPulse, live sliders |
scrolling_list |
scroll-into-view, then drift-free tracking |
full_tour |
7 steps, content cards, back/skip, storage |
gesture_teaching |
a drag taught with an arc, a pinch with a sweep |
theming |
ring stacks, and the pointer as hand / emoji / SVG / animated |
Platforms
Android, iOS, web, macOS, Windows and Linux.
Contributing
See CONTRIBUTING.md. Two rules that will not bend: no runtime dependencies, and no guessed timing.
License
MIT © Shreyash Bhardwaj