tutorial_bubbles
Guided tutorial bubbles and spotlights for Flutter apps.
tutorial_bubbles helps you highlight widgets or virtual screen regions, point to them with a bubble, and build onboarding flows without rewriting your UI.
Why use it
- Highlight keyed widgets or virtual
Recttargets - Show a one-off spotlight or a full multi-step tutorial
- Use solid or gradient bubbles
- Add arrow, glow, and target highlight effects
- Support rounded or oval targets
- Continue tutorials across screens
- Save, resume, and mark tutorials complete
- Prepare steps asynchronously before measuring targets
- Override behavior and placement per step
Installation
dependencies:
tutorial_bubbles: ^0.1.0
Then run:
flutter pub get
Quick start
There are two main ways to use the package:
TutorialBubbleOverlayfor a single spotlightTutorialEnginefor a multi-step tutorial
Single spotlight
Use this when you want a single spotlight with fully manual target measurement.
import 'package:flutter/material.dart';
import 'package:tutorial_bubbles/tutorial_bubbles.dart';
class SpotlightExample extends StatefulWidget {
const SpotlightExample({super.key});
@override
State<SpotlightExample> createState() => _SpotlightExampleState();
}
class _SpotlightExampleState extends State<SpotlightExample> {
final GlobalKey _overlayKey = GlobalKey();
final GlobalKey _targetKey = GlobalKey();
Rect? _targetRect;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _measureTarget());
}
void _measureTarget() {
final targetContext = _targetKey.currentContext;
final overlayContext = _overlayKey.currentContext;
if (targetContext == null || overlayContext == null) {
return;
}
final targetBox = targetContext.findRenderObject() as RenderBox?;
final overlayBox = overlayContext.findRenderObject() as RenderBox?;
if (targetBox == null || overlayBox == null) {
return;
}
final topLeft = targetBox.localToGlobal(Offset.zero, ancestor: overlayBox);
setState(() {
_targetRect = topLeft & targetBox.size;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureTarget());
return Stack(
key: _overlayKey,
children: [
Center(
child: ElevatedButton(
key: _targetKey,
onPressed: () {},
child: const Text('Target button'),
),
),
if (_targetRect != null)
Positioned.fill(
child: TutorialBubbleOverlay(
targetRect: _targetRect!,
preferredSide: TutorialBubbleSide.top,
highlightShape: const TutorialHighlightShape.roundedRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
backgroundGradient: const LinearGradient(
colors: <Color>[
Color(0xFF42A5F5),
Color(0xFFAB47BC),
],
),
bubbleHaloEnabled: true,
targetHaloEnabled: true,
child: const TutorialTextContent(
text: 'Tap this button to get started',
textColor: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
);
},
),
);
}
}
Multi-step tutorial
Use this when you want onboarding or multi-step guided flows.
import 'package:flutter/material.dart';
import 'package:tutorial_bubbles/tutorial_bubbles.dart';
class TutorialExample extends StatefulWidget {
const TutorialExample({super.key});
@override
State<TutorialExample> createState() => _TutorialExampleState();
}
class _TutorialExampleState extends State<TutorialExample> {
final GlobalKey _firstKey = GlobalKey();
final GlobalKey _secondKey = GlobalKey();
late final TutorialEngineController _controller = TutorialEngineController(
steps: [
TutorialStep(
target: TutorialTarget.key(_firstKey),
id: 'first_action',
bubbleBuilder: (context) => const TutorialTextContent(
text: 'Tap here first',
textColor: Colors.white,
),
),
TutorialStep(
target: TutorialTarget.key(_secondKey),
id: 'second_action',
preferredSide: TutorialBubbleSide.top,
beforeShow: (context, controller) async {
await Scrollable.ensureVisible(_secondKey.currentContext!);
},
behavior: const TutorialStepBehavior(
advanceOnBubbleTap: true,
),
bubbleBuilder: (context) => const TutorialTextContent(
text: 'Now look at this action',
textColor: Colors.white,
),
visuals: const TutorialVisuals(
arrowEnabled: false,
),
),
],
);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.start();
});
}
@override
Widget build(BuildContext context) {
return TutorialEngine(
controller: _controller,
persistence: const TutorialPersistence(id: 'tutorial_example'),
onComplete: (reason) {
debugPrint('Tutorial finished: $reason');
},
child: Scaffold(
appBar: AppBar(title: const Text('Tutorial demo')),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: ElevatedButton(
key: _firstKey,
onPressed: () => _controller.advance(),
child: const Text('First target'),
),
),
const SizedBox(height: 24),
Center(
child: OutlinedButton(
key: _secondKey,
onPressed: () {},
child: const Text('Second target'),
),
),
],
),
),
);
}
}
Virtual or painted targets
Use TutorialTarget.rect(...) when the tutorial should point to a synthetic or painted area instead of a keyed widget.
TutorialStep(
id: 'chart_hotspot',
target: TutorialTarget.rect((context) {
return const Rect.fromLTWH(120, 220, 160, 72);
}),
preferredSide: TutorialBubbleSide.bottom,
bubbleBuilder: (context) => const TutorialTextContent(
text: 'This summary band is painted directly on the chart.',
),
)
Common customization
Bubble look
const TutorialVisuals(
bubbleCornerRadius: 24,
bubbleBackgroundGradient: LinearGradient(
colors: <Color>[Color(0xFF42A5F5), Color(0xFFAB47BC)],
),
bubbleHaloEnabled: true,
)
Arrow
const TutorialVisuals(
arrowEnabled: true,
arrowHeadLength: 10,
arrowHaloEnabled: true,
)
Or directly on the overlay:
TutorialBubbleOverlay(
targetRect: targetRect,
arrowEnabled: true,
arrowGradient: const LinearGradient(
colors: <Color>[Color(0xFF42A5F5), Color(0xFFAB47BC)],
),
child: const TutorialTextContent(text: 'Example'),
)
Target highlight shape
const TutorialHighlightShape.rect()
const TutorialHighlightShape.roundedRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
)
const TutorialHighlightShape.oval()
Target glow
const TutorialVisuals(
targetHaloEnabled: true,
targetHaloColor: Color(0xB3FFFFFF),
targetShineEnabled: true,
targetShineColor: Color(0x80FFFFFF),
)
Global defaults with per-step overrides
TutorialEngine(
controller: controller,
globalVisuals: const TutorialVisuals(
overlayColor: Color(0xB3000000),
bubbleBackgroundGradient: LinearGradient(
colors: <Color>[Color(0xFF42A5F5), Color(0xFFAB47BC)],
),
targetHaloEnabled: true,
textStyle: TextStyle(
color: Color(0xFFFFFFFF),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
child: child,
)
Step preparation with beforeShow
Use beforeShow to scroll, wait for route transitions, or prepare state before the engine measures the target.
TutorialStep(
target: TutorialTarget.key(filterChipKey),
beforeShow: (context, controller) async {
await Scrollable.ensureVisible(filterChipKey.currentContext!);
await Future<void>.delayed(const Duration(milliseconds: 150));
},
bubbleBuilder: (context) => const TutorialTextContent(
text: 'This chip may start offscreen, so we scroll first.',
),
)
Per-step interaction policy
Use TutorialStepBehavior when a step needs different interaction rules from the rest of the tutorial.
TutorialStep(
target: TutorialTarget.key(profileButtonKey),
behavior: TutorialStepBehavior(
allowTargetTap: false,
blockOutsideTarget: true,
onOverlayTap: (context) {
debugPrint('Overlay tapped');
},
),
bubbleBuilder: (context) => const TutorialTextContent(
text: 'Only the bubble should advance this step.',
),
)
Cross-route navigation steps
When a highlighted target pushes a new route, prefer letting the engine advance on the target tap itself.
TutorialStep(
target: TutorialTarget.key(openDetailsKey),
behavior: TutorialStepBehavior(
allowTargetTap: true,
advanceOnTargetTap: true,
onTargetTap: (context) async {
Navigator.of(context).pushNamed('/details');
},
),
bubbleBuilder: (context) => const TutorialTextContent(
text: 'Tap to open details.',
),
)
Then let the next step wait for the destination UI to settle:
TutorialStep(
target: TutorialTarget.key(detailsHeaderKey),
beforeShow: (context, controller) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
},
bubbleBuilder: (context) => const TutorialTextContent(
text: 'Now we are on the details screen.',
),
)
Tips:
- Do not wait for
Navigator.push(...)to complete before advancing; that future completes when the route is popped. - Avoid wiring the same navigation action in both the target widget's
onPressedandonTargetTapfor the active tutorial step. - If the route transition can be tapped repeatedly, guard against duplicate pushes in app code.
Main API
TutorialBubbleOverlay
Use for a one-off spotlight.
Most important options:
targetRectchildpreferredSideoverlayColorbackgroundColorbackgroundGradientbubbleCornerRadiuspaddinghighlightShapeallowTargetTaptargetHaloEnabledtargetShineEnabledarrowEnabledarrowColorarrowGradientblockOutsideTargetonTargetTaponBackgroundTap
TutorialEngine
Use for multi-step tutorials.
Main options:
controllerchildpersistenceadvanceOnBubbleTapadvanceOnOverlayTapglobalVisualsonComplete
Compatibility options:
persistenceIdcheckpointSteps
TutorialEngineController
Controls tutorial flow.
Main methods:
start()advance()skip()goBack()finish()jumpTo(index)
TutorialStep
Defines a single step.
targetbubbleBuildervisualsbeforeShowpreferredSidebehaviorid
TutorialTarget
Target types:
TutorialTarget.key(GlobalKey)TutorialTarget.rect((context) => Rect)
TutorialStepBehavior
Behavior fields:
advanceOnBubbleTapadvanceOnOverlayTapadvanceOnTargetTapallowTargetTapblockOutsideTargetonTargetTaponOverlayTap
TutorialPersistence
Persistence fields:
idsaveStrategycheckpointsclearOnCompletecompletedKey
TutorialVisuals
Shared or per-step visual settings.
Common fields:
bubbleBackgroundColorbubbleBackgroundGradientbubbleCornerRadiusoverlayColorarrowEnabledarrowColorarrowGradientarrowHeadLengthbubbleHaloEnabledtargetHaloEnabledtargetShineEnabledhighlightShapearrowHaloEnabledtextStyle
TutorialTextContent
Recommended text widget for overlay and engine usage.
TutorialTextBubble
Convenience text bubble when you want a self-contained bubble widget.
TutorialBubble
Reusable bubble container for custom content.
Interaction behavior
By default, the spotlight keeps the highlighted target interactive and blocks taps outside it.
You can react to taps like this:
TutorialBubbleOverlay(
targetRect: targetRect,
onTargetTap: () {
debugPrint('Target tapped');
},
onBackgroundTap: () {
debugPrint('Background tapped');
},
child: const TutorialTextContent(text: 'Tap the highlighted control'),
)
In engine mode:
- engine-level
advanceOnBubbleTapandadvanceOnOverlayTapstill work - per-step behavior overrides are available through
TutorialStep.behavior
Persistence
Tutorial progress can be saved automatically.
Save on every step change:
TutorialEngine(
controller: controller,
persistence: const TutorialPersistence(
id: 'main_onboarding',
),
child: child,
)
Save only at selected checkpoints:
TutorialEngine(
controller: controller,
persistence: const TutorialPersistence(
id: 'main_onboarding',
saveStrategy: TutorialSaveStrategy.checkpointsOnly,
checkpoints: {0, 2, 4},
),
child: child,
)
Mark a tutorial completed forever while keeping resume separate from completion:
TutorialEngine(
controller: controller,
persistence: const TutorialPersistence(
id: 'main_onboarding',
clearOnComplete: true,
completionPersistencePolicy:
TutorialCompletionPersistencePolicy.completedOrSkipped,
),
child: child,
)
Completion persistence policies:
completedOnly- only advancing through the final step suppresses the tutorial forevercompletedOrSkipped- finishing normally or callingskip()suppresses the tutorial foreveralways-completed,skip(), andfinish()all suppress the tutorial forever
Works across screens
To let the tutorial follow navigation, place TutorialEngine above the app's routed content:
MaterialApp(
builder: (context, child) {
return TutorialEngine(
controller: controller,
child: child ?? const SizedBox.shrink(),
);
},
home: const FirstScreen(),
)
Tips
- Use
TutorialTextContentinside overlays and step builders - Use
TutorialTarget.rect(...)for painted charts, canvases, and synthetic hotspots - Use
TutorialHighlightShape.roundedRect(...)for rounded buttons and cards - Use
TutorialBubbleSide.automaticwhen you want the package to choose the best side - Put shared styling in
globalVisuals, then override only special steps - Use
beforeShowfor scrolling, navigation settling, and delayed layouts
Notes
- The package can target widgets by
GlobalKeyor authoredRectresolvers - It works well with buttons, text, icons, cards, and custom widgets
- Some widgets may paint shadows outside their visible bounds; use highlight shape and glow settings to get the cleanest result for your UI
Example app
The repository includes an example app under example/.
Run it with:
cd example
flutter pub get
flutter run
License
MIT. See LICENSE.