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.

pub package CI License: MIT

▶ Try the live demo

A spotlight with custom rings over an image, an animated hand demonstrating a pinch, and a caller-supplied overlay card

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.

  1. 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.
  2. 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.

Auto-detected circle, rounded rect and stadium cut-outs, plus a custom diamond path, each with its own ring stack

👆 Animated hand pointer — guide users with fully customisable movement and timing.

The hand pointer gliding between three targets with live duration, rotation and arc controls

🤏 Gesture teaching — demonstrate interactions like pinch and drag, not just where to tap.

A spotlight with custom rings over an image, an animated hand demonstrating a pinch, and a caller-supplied overlay card

🧭 Multi-step guided tours — progress tracking, skip, back and step-by-step navigation.

A seven-step guided tour advancing through content cards with progress, back and skip controls

🎨 Customisation — customise spotlight rings, barriers, pointers and artwork to match your app.

A neon ring stack, then the pointer swapped through built-in hand, emoji, SVG cursor and an animated reticle

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.

Shapesauto() (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 artworkPointerStyle.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: tapTargettargetOnly(), 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.themeTheme.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.disableAnimations skips glides and ring pulses; the dim and cut-out remain.
  • semanticLabel is announced via SemanticsService.
  • AlignmentDirectional anchors and PointerStyle.flipForRtl support 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 pumpAndSettle will time out with a pulsing spotlight up — pump explicit durations, or use RingPulse.none() in tests.
  • Futures resolve on animation completion — start them, pump the duration, then assert; don't await before pumping. See test/harness.dart for 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

Libraries

keyspot
Spotlights and guided hand gestures for Flutter — anchored to a GlobalKey, tracked live every frame, never drifting.