spotlight_guide 0.1.6
spotlight_guide: ^0.1.6 copied to clipboard
A Flutter overlay guide for spotlighting widgets with dynamic targets, reveal scrolling, custom hints, and robust widget tests.
Spotlight Guide #
SpotlightGuidePortal is a reusable onboarding overlay for highlighting one or more widgets on a page. It supports target holes, ready-made and custom hint UI, connected bubble arrows, pointer widgets, RTL-aware anchor positions and semantic placement, automatic placement, reveal scrolling, lazy-list preparation, and multiple hints in the same step.
Preview #
| Basic flow | Pointer hint | Same-step hints |
|---|---|---|
![]() |
![]() |
![]() |
| Same-step auto scroll | Lazy target reveal | Barrier dismiss |
![]() |
![]() |
![]() |
| Custom anchors and groups | Horizontal auto | Target decoration |
![]() |
![]() |
![]() |
Highlights #
- Static or runtime guide steps through
SpotlightGuidePortalController. - Ready-made text hints and a built-in tap pointer for quick onboarding.
- Multiple hints in one step, including sequential same-step auto scroll.
- Dynamic API-driven steps with skip/wait behavior for missing targets.
- Lazy-list targets using
onRevealbefore measurement. - Repeated target ids highlighted as a group, with optional
anchorId. - Shape-aware target hole decoration, custom anchor positions, interactive bubble arrows, barrier style, and hints.
When To Use It #
Use spotlight_guide when a guide needs to survive real app complexity:
scrolling pages, lazy lists, optional API-driven targets, repeated list rows,
multiple highlights in one step, or custom visual treatment around the target
hole. For a tiny fixed three-step tour, start with SpotlightGuideTextHint and
only move to lower-level widgets when the design needs them.
Public Entry #
Import the public entry:
import 'package:spotlight_guide/spotlight_guide.dart';
The implementation lives here:
lib/spotlight_guide.dart
lib/src/api/
lib/src/runtime/
lib/src/hints/
lib/src/layout/
lib/src/painting/
lib/src/utils/
Runtime files are small collaborators rather than page code. For example,
runtime/reveal_scroll_strategy.dart owns reveal scroll decisions, while
api/portal.dart keeps the Flutter widget and State orchestration together.
Documents #
README.md
Overview and common usage.
CONTRIBUTING.md
Maintainer rules, architecture map, invariants, and required verification.
CHANGELOG.md
Current component contract and maintenance notes.
doc/reference.md
Public parameter relationships and built-in hint types.
doc/examples.md
Copyable examples for saving-page style, lazy lists, and same-step targets.
doc/troubleshooting.md
Symptom-based debugging guide.
Before changing code in this component, read CONTRIBUTING.md first.
Example App #
The example app is a scenario playground. The entry stays small in
example/lib/main.dart, while each usage pattern lives in its own file:
example/lib/src/scenarios/basic_steps_scenario.dart
example/lib/src/scenarios/pointer_hint_scenario.dart
example/lib/src/scenarios/same_step_hints_scenario.dart
example/lib/src/scenarios/same_step_auto_scroll_scenario.dart
example/lib/src/scenarios/lazy_target_reveal_scenario.dart
example/lib/src/scenarios/dynamic_steps_scenario.dart
example/lib/src/scenarios/side_anchor_scenario.dart
example/lib/src/scenarios/large_group_anchor_scenario.dart
example/lib/src/scenarios/target_decoration_scenario.dart
example/lib/src/scenarios/custom_anchor_scenario.dart
example/lib/src/scenarios/controller_usage_scenario.dart
Run it with:
cd example
flutter run -d ios
Basic Usage #

SpotlightGuidePortal(
enabled: shouldShowGuide,
steps: <SpotlightGuideStep>[
SpotlightGuideStep.item(
SpotlightGuideStepItem(
targetId: 'more-button',
hintBuilder: (BuildContext context, SpotlightGuideStepContext guide) {
return SpotlightGuideTextHint(
guide: guide,
title: 'More actions',
message: 'Open this menu for advanced options.',
);
},
),
),
],
onFinish: markGuideSeen,
child: SpotlightGuideTarget(
id: 'more-button',
child: IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: openMore,
),
),
)
SpotlightGuideTextHint is the quickest built-in hint. It shows progress,
Back/Next/Done actions, scrolls long content inside the bubble, and still uses
the same placement, margin, pointer, and decoration system as custom hints.
Pointer Hints #

Use a pointer when the guide should visually point at a small tap target. A
pointer is any widget: the built-in tap pointer is only a shortcut, and you can
replace it with Image.asset, CustomPaint, an icon badge, or an app-specific
animation when needed.
Pointer direction follows the resolved hint placement. Use physical
left/right only for fixed layouts, semantic start/end when the side
should mirror in RTL, and horizontalAuto or auto when localization or
responsive layout can move the target. If a pointer is only decorative and the
bubble arrow should still point directly at the target, set
anchorMode: SpotlightGuidePointerAnchorMode.target.
pointerAnchorPosition chooses which point inside the pointer widget attaches
to the target. targetAnchorPosition then chooses which point inside the
pointer the bubble arrow attaches to. For example, center(4) keeps the
pointer contact stable and moves the arrow attachment 4 logical pixels from the
pointer center. Negative offsets are allowed.
gap is the signed main-axis distance for the active anchor chain. With the
default pointer anchor mode, the pointer touches the target side and gap is
the distance from the pointer's far edge to the bubble anchor tip. If the bubble
uses SpotlightGuideNoAnchor, the hint edge is treated as that tip. Without a
pointer, or when the pointer uses
anchorMode: SpotlightGuidePointerAnchorMode.target, gap remains the target
to bubble distance.
Use SpotlightGuideHintPointer.targetGap when the pointer itself should sit
away from the target. Positive values move it away from the target in the
resolved placement direction, negative values pull it back toward the target,
and zero keeps the pointer touching the target. Custom pointer widgets can omit
size to use the child's laid-out size; provide size when an image or
animation should reserve a stable visual slot.
Use visualOffset only for small asset-level nudges. It moves the pointer
widget's painted content, but does not move the target anchor, pointer layout
slot, bubble anchor, or bubble position. Use
SpotlightGuidePointerOffset.directional when the horizontal nudge should
mirror in RTL.
For directional artwork, keep the pointer child as the default asset and use
builder to wrap it with resolved layout information. The builder receives a
SpotlightGuidePointerContext; targetDirection tells you where the target is
relative to the pointer after auto, start/end, and RTL are resolved.
Use rotationToTarget() when the pointer should face the target. Pass from
when your asset's unrotated pose does not point up. up() is the default
source pose. upRight() describes a northeast-facing asset, and upRight(0) is
identical to upRight(). Direction constructors also accept a clockwise offset
from that named pose, such as right(math.pi / 2).
The returned rotation is resolved against the current target side, so the same source pose can rotate clockwise on one side and counterclockwise on the opposite side. For example, a right-side pointer and a left-side pointer mirror each other automatically.
SpotlightGuideStepItem(
targetId: 'more-button',
placement: SpotlightGuidePlacement.bottom,
gap: 10,
decoration: const SpotlightGuideBubbleDecoration(
anchor: SpotlightGuideNoAnchor(),
),
hintBuilder: (BuildContext context, SpotlightGuideStepContext guide) {
return SpotlightGuideTextHint(
guide: guide,
title: 'Tap here',
message: 'The pointer can be any widget.',
pointer: SpotlightGuideHintPointer.tap(
anchorMode: SpotlightGuidePointerAnchorMode.pointer,
visualOffset: SpotlightGuidePointerOffset.directional(end: 2),
builder: (
BuildContext context,
SpotlightGuidePointerContext pointer,
Widget child,
) {
return Transform.rotate(
angle: pointer.rotationToTarget(),
child: child,
);
},
),
);
},
)
For example, with import 'dart:math' as math;, an image whose unrotated pose
points right and is drawn another 90 degrees clockwise from that right axis can
use:
Transform.rotate(
angle: pointer.rotationToTarget(
from: SpotlightGuidePointerDirection.right(math.pi / 2),
),
child: child,
)
Multiple Hints In One Step #

Use SpotlightGuideStep(items: ...) when several hints should appear together. A single item can also highlight several targets with targetIds.
SpotlightGuideStep(
items: <SpotlightGuideStepItem>[
SpotlightGuideStepItem(targetId: 'calendar', hintBuilder: buildCalendarHint),
SpotlightGuideStepItem(targetId: 'more', hintBuilder: buildMoreHint),
],
)
Repeated Target IDs #

When several mounted SpotlightGuideTarget widgets use the same id, the
component treats them as one logical target group. All instances are highlighted
and the default placement anchor is the union of that group. The guide does not
pick the first or last mounted instance.
If the group should be highlighted but one instance should be the precise
anchor, set anchorId on that SpotlightGuideTarget and point
anchorTargetId at it. This keeps the target in the normal registration chain,
so insertion, removal, and active hint rebuilds continue to work.
SpotlightGuideTarget(
id: 'summary-card-group',
anchorId: 'summary-total-cost-anchor',
child: totalCostCard,
)
SpotlightGuideStepItem(
targetId: 'summary-card-group',
anchorTargetId: 'summary-total-cost-anchor',
hintBuilder: buildTotalCostHint,
)
For repeated list rows where only one row should be highlighted, use a unique id
such as order-row-${order.id} or use targetKey.
Target Decoration #
Use targetDecoration when the spotlight hole needs a specific shape, padding,
outer rings or glow. The decoration paints on the overlay only; it does not wrap
or modify the real target widget.

Use translucent SpotlightGuideTargetRingLayers for a crisp border-style halo,
SpotlightGuideTargetGlowLayer for a blurred soft halo, or
SpotlightGuideTargetDashedOutlineLayer for temporary selection and review
states. Layers paint in list order and follow the resolved target shape.
SpotlightGuideStep.item(
SpotlightGuideStepItem(
targetId: 'price-card',
targetDecoration: const SpotlightGuideTargetDecoration(
padding: EdgeInsets.all(8),
shape: SpotlightGuideRoundedRectTargetShape(
borderRadius: BorderRadius.all(Radius.circular(18)),
),
layers: <SpotlightGuideTargetLayer>[
SpotlightGuideTargetRingLayer(color: Color(0x1AFFFFFF), width: 16),
SpotlightGuideTargetRingLayer(color: Color(0x33FFFFFF), width: 8),
],
),
hintBuilder: buildPriceHint,
),
)
Built-in shapes include rounded rectangles and ovals. Implement
SpotlightGuideTargetShape for custom hole paths, or
SpotlightGuideTargetLayer for custom paint effects around the hole.
targetDecoration: const SpotlightGuideTargetDecoration(
layers: <SpotlightGuideTargetLayer>[
SpotlightGuideTargetDashedOutlineLayer(
color: Colors.white,
width: 3,
dashLength: 10,
gapLength: 6,
outset: 8,
),
],
)
Dynamic Or API-Driven Steps #

For automatic guides, rebuild steps after data loads. A portal without an
external controller starts when steps first becomes non-empty.
SpotlightGuidePortal(
steps: guideStepsFromServer,
child: page,
)
For user-controlled or scenario-triggered guides, keep a controller and call
showSteps. steps can be omitted on the portal when all guide content comes
from an API or another runtime decision.
final controller = SpotlightGuidePortalController();
SpotlightGuidePortal(
controller: controller,
autoStart: false,
child: page,
);
final List<SpotlightGuideStep> steps = buildGuideSteps(response);
if (steps.isEmpty) {
controller.hide();
} else {
controller.showSteps(steps);
}
Unavailable targets are skipped by default, so stale or inapplicable target ids
do not leave a blank guide active. If one specific item is expected to appear
later without an immediate onReveal hook, override that item or portal to
SpotlightGuideMissingTargetBehavior.wait. Skipped unavailable steps are
removed from the active sequence, so
controller.total, controller.isLast, and the same values in
SpotlightGuideStepContext update as if those steps had been removed from the
list.
Use onStateChanged when external state depends on dynamic target availability
or guide progress. It fires when a step is shown, hidden, when portal steps
change while active, and when targets register or unregister.
SpotlightGuidePortal(
onStateChanged: (SpotlightGuideStateContext state) {
debugPrint(
'guide ${state.index + 1}/${state.total}, '
'resolved=${state.resolvedItemCount}',
);
},
steps: steps,
child: page,
)
Scroll And Lazy Targets #
| Same-step auto scroll | Lazy target reveal |
|---|---|
![]() |
![]() |
If a target is already built but outside a scrollable viewport, the default
SpotlightGuideRevealOptions calls Scrollable.ensureVisible. It does not
scroll when the target is already fully visible. Use
scrollPolicy: SpotlightGuideRevealScrollPolicy.always when a step should
always realign the target, or visibilityPadding when sticky headers or bottom
bars should count as unsafe space. During reveal scrolling, the default
presentation keeps the barrier visible and waits until scrolling/layout settles
before showing hints and spotlight holes. Use
SpotlightGuideLiveRevealPresentationStrategy when an app intentionally wants
resolved hints and holes to track animated scrolling.
When one hint highlights a large targetIds group, set anchorTargetId to the
main target. If the full group is too large to fit, default reveal prioritizes
the anchor, so an already visible anchor will not scroll the page just because
the larger highlighted area extends outside the viewport. Override
scrollTargetPolicy when a page needs different behavior.
SpotlightGuideRevealOptions(
scrollTargetPolicy: SpotlightGuideRevealScrollTargetPolicy.anchorTarget,
)
If a target is not built yet, such as a lazy list row, use onReveal to scroll close to it, switch tabs, expand a section, or load data. After the callback completes, the portal waits for layout and then applies the default reveal behavior.
SpotlightGuideStepItem(
targetId: 'order-row-50',
onReveal: (SpotlightGuideRevealContext context) async {
await context.scrollToIndex(
controller: listController,
index: 50,
itemExtent: rowHeight,
alignment: 0.2,
);
},
revealOptions: const SpotlightGuideRevealOptions(
alignment: 0.5,
duration: Duration(milliseconds: 250),
),
hintBuilder: buildHint,
)
For multiple items in the same step, SpotlightGuideStepAutoScrollOptions can briefly scroll to hidden later targets so users understand the whole introduced area. It is enabled by default and starts when a later target is not fully visible, or when a not-yet-built later target provides an onReveal hook. During the default auto-scroll transition, the outgoing hint is hidden before scrolling starts and the next hint appears after the target settles, so hints do not detach from moving or offscreen targets. Use autoScrollOptions.onAutoScrollItemChanged to react when that focused item changes. The callback receives [SpotlightGuideAutoScrollItemContext] with itemIndex, itemTotal, highlightTargetIds, and optional [SpotlightGuideStepItem.key]. Use one item with targetIds when a single hint should light several registered targets at once.
Barrier Style #
Configure the guide background with SpotlightGuideBarrierStyle. Color and
blur are clipped to the dimmed area, so highlighted targets keep their original
brightness and sharpness. Set it on SpotlightGuidePortal for a shared default;
set it on a SpotlightGuideStep to override only the fields that step needs.
Visual holes are clipped to the visible overlay before painting, so oversized
targets keep their rounded corners inside the screen.
SpotlightGuidePortal(
barrier: const SpotlightGuideBarrierStyle(
color: Color(0x99000000),
blurSigma: 4,
),
steps: <SpotlightGuideStep>[
SpotlightGuideStep(
// Keeps the portal blur, only changes the color for this step.
barrier: const SpotlightGuideBarrierStyle(color: Color(0x66000000)),
items: items,
),
],
child: page,
)
Barrier Taps #

The dim barrier absorbs taps so they never reach the page behind the guide. By default, tapping empty space does not close or advance the guide.
Use barrierDismissBehavior for common close behavior:
SpotlightGuidePortal(
barrierDismissBehavior: SpotlightGuideBarrierDismissBehavior.onComplete,
steps: steps,
child: page,
)
onComplete only finishes after the last step is visible and any same-step
auto-scroll sequence has reached its final item. Use anytime when the product
intentionally allows closing mid-flow.
Provide onBarrierTap for custom behavior such as tap-anywhere-to-continue.
The callback receives the active controller, so this works even without
supplying an external one.
SpotlightGuidePortal(
onBarrierTap: (controller) => controller.next(),
steps: steps,
child: page,
)
Show Once #
The package does not depend on a persistence plugin. Keep the guide lightweight and connect it to your own local or remote state:
SpotlightGuidePortal(
enabled: !settings.hasSeenHomeGuide,
steps: homeGuideSteps,
onFinish: () => settings.markHomeGuideSeen(),
child: page,
)
Tap The Highlighted Target #
Set allowTargetInteraction: true on an item to let taps inside its spotlight
hole pass through to the real widget behind the guide. This is the "tap this
button to continue" pattern. The barrier keeps absorbing taps everywhere else.
Only the target rect itself passes through; the surrounding padding from
targetDecoration.padding stays absorbed so a neighbouring control is not hit
by accident.
SpotlightGuideStep.item(
SpotlightGuideStepItem(
targetId: 'send-button',
allowTargetInteraction: true,
hintBuilder: (context, guide) => const Text('Tap send to continue'),
),
)
Stepping Back And Jumping #

The controller can start the portal-owned sequence with showPortal() or a
runtime sequence with showSteps(steps). The controller and the guide context
inside a hintBuilder can both navigate or close the active guide with
next(), previous(), goTo(index), reset(), hide(), and finish().
previous() is a no-op on the first step, and goTo clamps the index.
hide() closes without onFinish; finish() closes and reports completion.
controller.showPortal();
controller.showSteps(steps);
controller.previous();
controller.goTo(2);
controller.hide();
RTL And Anchors #

Use SpotlightGuideAnchorPosition.start, center, or end for semantic alignment. start and end follow Directionality, so Arabic and other RTL layouts mirror correctly.
Custom UI #

hintBuilder may return:
SpotlightGuideBubbleHintfor a common bubble plus optional pointer image.SpotlightGuideBubblefor a connected bubble arrow without pointer.- Any custom widget, image composition, or app-specific guide UI.
The layout data is available through SpotlightGuideStepContext.
Use guide.indicatorDirection when a custom hint needs to know which physical
side the anchor is on. For example, a hint placed below a target has an
up indicator because the anchor sits on the bubble's top edge and points
toward the target. Path-based custom anchors can read the same resolved side
from SpotlightGuideAnchorPathBuilder.direction inside addToPath.
Use guide.targetAnchorPosition when custom pointer or bubble layouts need to
resolve the same anchor position that built-in hints use.
For the full API and built-in hint catalog, see doc/reference.md.

