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 Rect targets
  • 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:

  1. TutorialBubbleOverlay for a single spotlight
  2. TutorialEngine for 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 onPressed and onTargetTap for 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:

  • targetRect
  • child
  • preferredSide
  • overlayColor
  • backgroundColor
  • backgroundGradient
  • bubbleCornerRadius
  • padding
  • highlightShape
  • allowTargetTap
  • targetHaloEnabled
  • targetShineEnabled
  • arrowEnabled
  • arrowColor
  • arrowGradient
  • blockOutsideTarget
  • onTargetTap
  • onBackgroundTap

TutorialEngine

Use for multi-step tutorials.

Main options:

  • controller
  • child
  • persistence
  • advanceOnBubbleTap
  • advanceOnOverlayTap
  • globalVisuals
  • onComplete

Compatibility options:

  • persistenceId
  • checkpointSteps

TutorialEngineController

Controls tutorial flow.

Main methods:

  • start()
  • advance()
  • skip()
  • goBack()
  • finish()
  • jumpTo(index)

TutorialStep

Defines a single step.

  • target
  • bubbleBuilder
  • visuals
  • beforeShow
  • preferredSide
  • behavior
  • id

TutorialTarget

Target types:

  • TutorialTarget.key(GlobalKey)
  • TutorialTarget.rect((context) => Rect)

TutorialStepBehavior

Behavior fields:

  • advanceOnBubbleTap
  • advanceOnOverlayTap
  • advanceOnTargetTap
  • allowTargetTap
  • blockOutsideTarget
  • onTargetTap
  • onOverlayTap

TutorialPersistence

Persistence fields:

  • id
  • saveStrategy
  • checkpoints
  • clearOnComplete
  • completedKey

TutorialVisuals

Shared or per-step visual settings.

Common fields:

  • bubbleBackgroundColor
  • bubbleBackgroundGradient
  • bubbleCornerRadius
  • overlayColor
  • arrowEnabled
  • arrowColor
  • arrowGradient
  • arrowHeadLength
  • bubbleHaloEnabled
  • targetHaloEnabled
  • targetShineEnabled
  • highlightShape
  • arrowHaloEnabled
  • textStyle

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 advanceOnBubbleTap and advanceOnOverlayTap still 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 forever
  • completedOrSkipped - finishing normally or calling skip() suppresses the tutorial forever
  • always - completed, skip(), and finish() 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 TutorialTextContent inside overlays and step builders
  • Use TutorialTarget.rect(...) for painted charts, canvases, and synthetic hotspots
  • Use TutorialHighlightShape.roundedRect(...) for rounded buttons and cards
  • Use TutorialBubbleSide.automatic when you want the package to choose the best side
  • Put shared styling in globalVisuals, then override only special steps
  • Use beforeShow for scrolling, navigation settling, and delayed layouts

Notes

  • The package can target widgets by GlobalKey or authored Rect resolvers
  • 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.

Libraries

tutorial_bubbles