Overmark

Spotlight coach marks for Flutter onboarding tours. Dim the screen, cut a hole around a widget, explain it in a card, move on to the next one — or wait until the user actually taps the highlighted control.

pub package license: MIT

Default Overmark tour    Custom-themed Overmark tour

Default theme · Custom theme

Action step — tap the real control to advance    Whisper mode — soft non-blocking tips    Lazy list — prepare, scroll, then reveal

Action step · Whisper tips · Lazy list + prepare

  • No dependencies. Just Flutter. Nothing is pulled into your app.
  • Teach by doing. Action steps pass taps through the hole to the real widget and wait for completeAction.
  • Fits any theme. Every colour and text style falls back to the ambient ThemeData, so the default look is neutral in light and dark alike.
  • Customisable to the pixel. Restyle through OvermarkTheme, or replace the card entirely with your own widget.
  • Behaves like a real route. The system back gesture ends the tour, and the call returns a future telling you how it ended.
  • Keeps up. The spotlight tracks its anchor through rotation, resize, and scrolling, waits for lazy-list items to mount, and docks the card around the hole with an optional leader line.
  • Premium spotlight. Soft rim / glow, whisper (non-blocking) tips, card Pop-in, and optional haptics — all opt-in.

Install

dependencies:
  overmark: ^0.2.0

Quick start

Attach a GlobalKey to each widget you want to highlight, then start a tour.

import 'package:overmark/overmark.dart';

final GlobalKey searchKey = GlobalKey();
final GlobalKey shelfKey = GlobalKey();

// ...

TextField(key: searchKey);

// ...

final OvermarkOutcome outcome = await Overmark.show(
  context,
  steps: <OvermarkStep>[
    OvermarkStep(
      anchorKey: searchKey,
      title: 'Find your next book',
      message: 'Search by title, author, or topic.',
    ),
    OvermarkStep(
      anchorKey: shelfKey,
      title: 'Tap any cover',
      message: 'Every book is a short visual summary.',
    ),
  ],
);

Steps whose anchors are not mounted yet are waited on (see readiness below). If nothing becomes ready in time, the tour is not shown.

outcome is one of completed, skipped, dismissed, or notShown.

Action step recipe

Use an action gate when the user should press the real control — not Next.

final GlobalKey startKey = GlobalKey();
final OvermarkController controller = OvermarkController();

FilledButton(
  key: startKey,
  onPressed: () {
    startTimer();                 // your real work
    controller.completeAction();  // advance the tour
  },
  child: const Text('Start'),
);

await Overmark.show(
  context,
  controller: controller,
  steps: <OvermarkStep>[
    OvermarkStep(
      anchorKey: startKey,
      title: 'Start a session',
      message: 'Tap Start to begin.',
      gate: OvermarkStepGate.action,
    ),
  ],
);

While the gate is open, the hole passes taps through to the button, Next is hidden, and controller.next() is a no-op. Call completeAction from the control's onPressed / onTap after your real work. Skip, Back, and finish() still work.

Readiness & lazy lists

Anchors that are not laid out yet are polled until they appear (default 3s). For list items that need a scroll first, use prepare:

OvermarkStep(
  anchorKey: itemKey,
  title: 'Pinned item',
  message: 'Scrolls into view before the spotlight lands.',
  prepare: (BuildContext context) async {
    await Scrollable.ensureVisible(
      itemKey.currentContext!,
      alignment: 0.5,
    );
  },
)

Configure timeouts on OvermarkConfig (readinessTimeout, readinessInterval) or per step with readinessTimeout.

Show it only once

showOnce is the usual first-run pattern. Without a storage it remembers for the current launch only:

await Overmark.showOnce(
  context,
  tourId: 'home_tour_v1',
  steps: steps,
);

To remember across launches, hand it somewhere to write. Overmark has no dependencies, so the adapter lives in your app — with shared_preferences it is six lines:

class PrefsOvermarkStorage implements OvermarkStorage {
  @override
  Future<bool> hasShown(String tourId) async =>
      (await SharedPreferences.getInstance()).getBool('tour_$tourId') ?? false;

  @override
  Future<void> markShown(String tourId) async =>
      (await SharedPreferences.getInstance()).setBool('tour_$tourId', true);
}

await Overmark.showOnce(
  context,
  tourId: 'home_tour_v1',
  storage: PrefsOvermarkStorage(),
  steps: steps,
);

Mark policy

Policy When the tour is recorded as shown
onStart (default) After the first step is ready, just before the route is pushed
onComplete Only when the outcome is completed
onAction On the first completeAction (or on complete if there is no action step)
await Overmark.showOnce(
  context,
  tourId: 'home_tour_v1',
  markPolicy: OvermarkMarkPolicy.onComplete,
  storage: PrefsOvermarkStorage(),
  steps: steps,
);

Bumping the tourId (home_tour_v2) is how you re-run a tour after redesigning a screen.

Theming

Leave a field null and it is derived from Theme.of(context). Set only what you want to change:

await Overmark.show(
  context,
  theme: OvermarkTheme(
    scrimColor: const Color(0xFF1B0E2B).withAlpha(220),
    spotlightBorderColor: const Color(0xFFFFC857),
    spotlightRadius: 28,
    cardRadius: 28,
    buttonColor: const Color(0xFFFFC857),
  ),
  steps: steps,
);
Group Fields
Spotlight scrimColor, spotlightBorderColor, spotlightBorderWidth, spotlightRadius, spotlightPadding
Soft mask spotlightRimBlur, spotlightRimColor, spotlightGlowBlur, spotlightGlowColor
Pulse spotlightPulse, spotlightPulseAmplitude, spotlightPulseDuration
Card motion cardMotion (none / fade / fadeSlide / fadeScale), cardMotionOffset, cardMotionScaleBegin
Card cardColor, cardRadius, cardPadding, cardMargin, cardGap, cardShadows
Leader showLeaderLine, leaderLineColor, leaderLineWidth
Text titleStyle, messageStyle, skipStyle, buttonLabelStyle
Button buttonColor, buttonPadding, buttonRadius
Progress dots indicatorSize, indicatorSpacing, indicatorColor, indicatorActiveColor

Translate the controls with OvermarkLabels:

labels: const OvermarkLabels(
  next: 'Lanjut',
  previous: 'Kembali',
  skip: 'Lewati',
  done: 'Mengerti',
),

Behaviour

config: const OvermarkConfig(
  advanceOnBarrierTap: true,   // tap scrim to move on (ignored during action gates / whisper)
  showSkip: true,
  showPrevious: true,          // Back on the default card
  showIndicator: true,
  animationDuration: Duration(milliseconds: 320),
  animationCurve: Curves.easeOutCubic,
  cardMotionDuration: null,    // null → animationDuration
  cardMotionCurve: null,       // null → strong ease-out Pop-in
  transitionDuration: Duration(milliseconds: 180),
  ensureVisible: true,
  scrollAlignment: 0.5,        // 0.0 leading, 0.5 center
  readinessTimeout: Duration(seconds: 3),
  placement: OvermarkCardPlacement.auto,
  dismissible: true,           // false locks system / gesture back until Skip, Next, or finish
  useRootNavigator: true,
  pointerMode: OvermarkPointerMode.modal, // or .whisper for non-blocking tips
  haptics: OvermarkHaptics.off,           // .selection or .light
),

Whisper mode

// Non-blocking tip — taps pass through the scrim; the card stays interactive.
await Overmark.show(
  context,
  config: const OvermarkConfig(pointerMode: OvermarkPointerMode.whisper),
  theme: OvermarkTheme(scrimColor: Colors.black.withAlpha(55)),
  steps: steps,
);

Hide the arrow between the card and the spotlight with theme:

theme: const OvermarkTheme(showLeaderLine: false),

With dismissible: false, the system back button and iOS/Android back gesture do not end the tour. The user still leaves through Skip, Next/Done, completeAction, or controller.finish().

Per-step overrides

Any step can override the spotlight shape, corner radius, padding, gate, and prepare callback:

OvermarkStep(
  anchorKey: profileTabKey,
  title: 'Your progress',
  message: 'Streaks and stats live here.',
  shape: OvermarkShape.circle,
  padding: const EdgeInsets.all(12),
)

Your own card

When theming is not enough, replace the card for a step. You get the tour state and the navigation callbacks — including isAwaitingAction and completeAction:

OvermarkStep(
  anchorKey: shelfKey,
  cardBuilder: (BuildContext context, OvermarkStepDetails details) {
    return MyCard(
      step: details.index + 1,
      of: details.stepCount,
      isLast: details.isLast,
      awaitingAction: details.isAwaitingAction,
      anchor: details.anchorRect,
      onNext: details.next,
      onBack: details.previous,
      onClose: details.skip,
      onActionDone: details.completeAction,
    );
  },
)

Driving the tour yourself

final OvermarkController controller = OvermarkController();

Overmark.show(context, controller: controller, steps: steps);

controller.next();
controller.previous();
controller.completeAction();
controller.skip();
controller.finish();

Callbacks are available too: onStepChanged, onSkip, and onFinish.

Version support

Dart >=3.4.0 <4.0.0
Flutter >=3.22.0
Package platforms Android, iOS, web, macOS, Windows, Linux

Pure Dart and Flutter widget tests run in CI on the oldest supported Flutter and on current stable.

Example

A runnable demo lives in example/: a first-run tour with an action step, an Overmark lab (whisper, action, mark policies, locked back, leader line), a Sessions tab for lazy-list + prepare, a custom theme, a light/dark toggle, and a shared_preferences storage adapter.

The GIFs at the top were recorded from that app: default and custom themes, then action steps, whisper tips, and scrollable / lazy anchors.

cd example
flutter run

License

MIT

Libraries

overmark
Spotlight coach marks for Flutter onboarding tours.