keyspot 0.3.1 copy "keyspot: ^0.3.1" to clipboard
keyspot: ^0.3.1 copied to clipboard

Interactive onboarding and guided tours for Flutter. Highlight widgets, teach gestures with an animated pointer, and track targets live so nothing drifts.

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

14
likes
160
points
87
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Interactive onboarding and guided tours for Flutter. Highlight widgets, teach gestures with an animated pointer, and track targets live so nothing drifts.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#onboarding #showcase #tutorial #spotlight #walkthrough

License

MIT (license)

Dependencies

flutter

More

Packages that depend on keyspot