pinpoint
Bubbles with an arrow that point at any widget, with optional dimming and spotlight, and guided tours built from them. Every part is customizable, and right-to-left layouts work out of the box.
Features
- Anchored bubbles on the top, bottom, start or end side of any widget.
- An arrow that keeps pointing at the target, even when the bubble is pushed away from the screen edge.
- Auto flip to the opposite side when there's no room.
- Follows the target while scrolling, and hides while it's scrolled out of view.
- Dim the screen, or spotlight the target through a rounded hole.
- Guided tours across widgets anywhere in the app.
- Full control over the look: color, corners, padding, border, shadows, arrow size. Per bubble, or app-wide with a theme.
- RTL-first:
startandendfollow the reading direction. - Accessible: screen reader announcements, a dismiss action, the Escape key, and reduced motion.
- No dependencies beyond Flutter.
Getting started
flutter pub add pinpoint
Requires Flutter 3.32 or later.
Usage
Wrap the widget to point at in a Pinpoint, and show it with a
PinpointController:
final controller = PinpointController();
Pinpoint(
controller: controller,
direction: PinpointDirection.bottom,
content: (context, controller) => const Text('Tap here to start'),
child: const StartButton(),
)
controller.show();
The content builder gets the controller, so the bubble can close itself:
content: (context, controller) => Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Learn about cashback'),
IconButton(icon: const Icon(Icons.close), onPressed: controller.hide),
],
),
Placement
| Option | Values | Default |
|---|---|---|
direction |
top, bottom, start, end |
top |
alignment |
start, center, end |
center |
gap |
Distance from the arrow's tip to the target | 4 |
screenMargin |
Minimum distance from the screen's edges | 12 on all sides |
autoFlip |
Move to the opposite side when there's no room | true |
start and end follow the ambient TextDirection: in a right-to-left
layout, PinpointDirection.start puts the bubble on the right.
Dim and spotlight
Pinpoint(
controller: controller,
highlight: const PinpointHighlight.spotlight(
padding: EdgeInsets.all(12),
borderRadius: BorderRadius.all(Radius.circular(24)),
),
content: (context, controller) => const Text('Choose a store'),
child: const StoreCard(),
)
| Highlight | Looks | Taps |
|---|---|---|
PinpointHighlight.none() |
The screen as is | Reach everything behind the bubble |
PinpointHighlight.dim() |
The whole screen dimmed, target included | Blocked |
PinpointHighlight.spotlight() |
Dimmed, with a hole around the target | Only the target is tappable |
With a spotlight, the bubble is placed next to the hole rather than the target, so it never covers it.
Dismissing
By default, tapping anywhere outside the bubble and its target hides it.
Scrolling doesn't. With dismissOnTapOutside: true, the Escape key and the
screen reader's dismiss action hide it too. Set it to false to close the
bubble only from its own content.
Style
Pinpoint(
style: const PinpointStyle(
color: Colors.white,
foregroundColor: Colors.black87,
borderRadius: BorderRadius.all(Radius.circular(16)),
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),
side: BorderSide(color: Colors.black12),
shadows: [BoxShadow(color: Colors.black26, blurRadius: 16)],
arrow: PinpointArrow(width: 24, height: 12),
),
// ...
)
Every field is optional. The defaults come from the ambient Theme, and the
text and icon color adapts to contrast with the bubble's color.
To set a style for the whole app, add a PinpointTheme:
MaterialApp(
theme: ThemeData(
extensions: const [
PinpointTheme(style: PinpointStyle(color: Colors.white)),
],
),
)
Styles on a step, tour or bubble take precedence over the theme, field by field.
Guided tours
Mark the widgets a tour should visit with PinpointTarget, anywhere in the
app:
PinpointTarget(id: 'search', child: SearchButton())
PinpointTarget(id: 'profile', child: ProfileButton())
Then describe the steps and start the tour:
final tour = PinpointTour(
steps: [
PinpointStep(
targetId: 'search',
content: (context, tour) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Find any store in seconds.'),
Text('${tour.currentIndex! + 1} / ${tour.length}'),
TextButton(onPressed: tour.skip, child: const Text('Skip')),
TextButton(onPressed: tour.next, child: const Text('Next')),
],
),
),
PinpointStep(
targetId: 'profile',
direction: PinpointDirection.bottom,
content: (context, tour) => TextButton(
onPressed: tour.next,
child: const Text('Done'),
),
),
],
onFinish: () => print('Finished'),
onSkip: () => print('Skipped'),
);
tour.start();
- Inside
content, usenext(),previous(),skip()andfinish(), andcurrentIndex,length,isFirstandisLast. - Steps take the tour's
direction,alignment,style,highlightandgapunless they set their own. Tours spotlight their targets by default. - Tapping outside a tour step does nothing, unless the tour sets
dismissOnTapOutside: true, which makes it skip the tour. - Targets inside a scrollable are scrolled into view first.
- Steps whose target isn't mounted are skipped, and
onTargetNotFoundis called with their index. - If the current target leaves the tree, the tour stops without calling
onFinishoronSkip. PinpointTouris aChangeNotifier. Listen to it to react to step changes, and dispose it when you're done.
The bubble shape on its own
PinpointBubbleBorder is a regular ShapeBorder, so you can use the bubble
shape anywhere:
Container(
padding: const EdgeInsets.all(12),
decoration: const ShapeDecoration(
color: Colors.amber,
shape: PinpointBubbleBorder(arrowDirection: AxisDirection.down),
),
child: const Text('Hello'),
)
Limitations
- Lazy lists:
ListView.builderand similar lists don't build items far off screen, so aPinpointTargetthere isn't mounted and its tour step is skipped. Scroll the item into view before starting the tour, or use a non-lazy list. - Nested scrollables: a bubble hides when its target leaves the nearest scrollable, not outer ones.
- Screen readers behind a barrier: dim and spotlight block taps, but screen readers can still reach the widgets behind them.
License
MIT. See LICENSE.
Libraries
- pinpoint
- Anchored bubbles with a pointing arrow, spotlight highlighting, and guided tours.