pinpoint
Bubbles with an arrow that point at any widget, a spotlight to draw the eye, and guided tours built from them, across screens and with demo data. Every part is customizable, and right-to-left layouts work out of the box.
Spotlight animations · Tour buttons · Multi-screen tours with demo data
Contents
- Features
- Getting started
- Bubbles
- Dim and spotlight
- Content outside the bubble
- Guided tours
- Tours across screens and demo data
- Style and theme
- The bubble shape on its own
- Example app
- Limitations
- Migrating from 0.1.0
Features
- Anchored bubbles on the top, bottom, start or end side of any widget, with an arrow that keeps pointing at it, even when the bubble is pushed away from the screen edge.
- Rich tooltips: show a bubble on hover, long press or tap, with no controller.
- Smart placement: flips to the other side when there's no room, follows the target while scrolling, and hides while it's scrolled away.
- Dim the screen, or spotlight the target through a hole of any shape, with an optional pulse ring or iris opening.
- Guided tours across widgets anywhere in the app, with built-in Next, Previous and close buttons, inside the bubble or outside it.
- Tours across screens, with demo data for new users that gives way to the real data when the tour ends.
- Full control over the look, 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.
A single bubble: 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();
A tour: mark the widgets to visit with PinpointTarget, anywhere in the
app, then start a PinpointTour:
PinpointTarget(id: 'search', child: SearchButton())
PinpointTarget(id: 'profile', child: ProfileButton())
final tour = PinpointTour(
controls: const PinpointTourControls(showClose: true),
steps: [
PinpointStep(
targetId: 'search',
content: (context, tour) => const Text('Find any store in seconds.'),
),
PinpointStep(
targetId: 'profile',
direction: PinpointDirection.bottom,
content: (context, tour) => const Text('Your account lives here.'),
),
],
);
tour.start();
Bubbles
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),
],
),
Show on hover, long press or tap
Leave out the controller and let gestures on the child show the bubble, for a tooltip that can hold anything:
Pinpoint(
triggers: const {PinpointTrigger.hover, PinpointTrigger.longPress},
content: (context, controller) => const Text('Copies the link'),
child: IconButton(icon: const Icon(Icons.link), onPressed: copy),
)
| Trigger | Shows the bubble when |
|---|---|
hover |
The mouse rests on the child for hoverDelay (300ms by default). It stays while the pointer is over the child or the bubble, so the bubble's content can be clicked. |
longPress |
The child is long pressed, like Tooltip on touch screens. |
tap |
The child is tapped; tapping again hides it. The child still gets the tap. |
Combine them, as above, to cover both mouse and touch screens. Triggers work with a controller of your own too.
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.
Dismissing
With dismissible: true, the default, the user can close the bubble by
tapping anywhere outside it and its target, by pressing Escape, or with the
screen reader's dismiss action on the barrier. Scrolling never closes it.
Set it to false to close the bubble only from its own content.
Dim and spotlight
Pinpoint(
controller: controller,
highlight: const PinpointHighlight.spotlight(
padding: EdgeInsets.all(12),
shape: RoundedRectangleBorder(
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 |
The hole can be any ShapeBorder: CircleBorder() suits icons and
StadiumBorder() suits pill buttons. With a spotlight, the bubble is placed
next to the hole rather than the target, so it never covers it.
Spotlight animations
Both are off by default:
PinpointHighlight.spotlight(
// A ring that keeps growing out of the hole and fading.
pulse: const PinpointPulse(),
// The hole starts wide and closes in on the target when it appears.
iris: const PinpointIris(),
)
Tune them with PinpointPulse(color:, width:, spread:, period:) and
PinpointIris(duration:, curve:). Neither plays when the platform asks for
reduced motion.
When several bubbles show at once, the ones with a dim or spotlight barrier stay on top, so nothing appears over a dimmed screen.
Content outside the bubble
outsideContent places a widget on the other side of the target, for
example a button under a spotlighted element:
Pinpoint(
controller: controller,
highlight: const PinpointHighlight.spotlight(),
content: (context, controller) => const Text('Your new wallet'),
outsideContent: (context, controller) => FilledButton(
onPressed: controller.hide,
child: const Text('Got it'),
),
child: const WalletCard(),
)
When the bubble is above or below the target, it goes on the opposite side, or past the bubble if there's no room there. It fades with the bubble.
cornerContent pins a widget to the screen's top corner at the end of the
reading direction instead, inside the safe area, for example a skip button.
It moves to another corner when it would cover the target or the bubble.
Guided tours
Next, Previous and close buttons
controls adds the buttons to every step:
PinpointTour(
controls: const PinpointTourControls(
placement: PinpointControlsPlacement.outside, // or inside
showClose: true,
closePlacement: PinpointClosePlacement.screen, // or bubble
),
steps: [...],
)
-
outsideputs round Previous and pill-shaped Next buttons on the other side of the target;insideputs them in the bubble, under the content. -
Previous is hidden on the first step, and Next reads Done on the last.
-
The close button skips the tour. It sits in the bubble's top corner, or in the screen's, inside the safe area. In the screen, it moves to the next free corner rather than cover the target or the bubble.
-
showProgress: trueadds a row of dots to the bubble, one per step, with the current one stretched. Screen readers hear "Step 2 of 4"; translate it withprogressLabel.PinpointTourProgressis the same dots, for your own content. -
Change the text with
nextLabel,doneLabel,previousLabelandcloseLabel, hide Previous withshowPrevious: false, or draw your own buttons withbuilder: (context, tour) => ...and your own close button withcloseBuilder: (context, tour) => ...:PinpointTourControls( showClose: true, showProgress: true, closeBuilder: (context, tour) => TextButton(onPressed: tour.skip, child: const Text('Skip')), ) -
A step can turn them off with
showControls: false.
Steps
- Inside
content, usenext(),previous(),skip()andfinish(), andcurrentIndex,length,isFirstandisLast, to build your own buttons or a step counter. - Steps take the tour's
direction,alignment,style,highlightandgapunless they set their own. Tours spotlight their targets by default. - Set
advanceOnTargetTap: trueon the tour or a step to move on when the user taps the target itself. The target still gets the tap, so a button runs its own action too. - Tapping outside a tour step does nothing, unless the tour sets
dismissible: true, which makes it skip the tour. - Targets inside a scrollable are scrolled into view first.
- Steps whose target doesn't show up 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.
Showing a tour once
Pinpoint doesn't store anything, so you decide where "seen" lives. With shared_preferences, for example:
final prefs = await SharedPreferences.getInstance();
if (!(prefs.getBool('home_tour_seen') ?? false)) {
final tour = PinpointTour(
steps: steps,
onFinish: () => prefs.setBool('home_tour_seen', true),
onSkip: () => prefs.setBool('home_tour_seen', true),
);
tour.start();
}
Tours across screens and demo data
Moving between screens
A step's onNext runs when the tour moves forward from it, and onPrevious
when it moves back. Open and close screens there:
PinpointStep(
targetId: 'wallet-button',
onNext: (tour) => Navigator.of(context).push(walletRoute),
content: ...,
),
PinpointStep(
targetId: 'transactions', // on the wallet screen
onPrevious: (tour) => Navigator.of(context).pop(),
content: ...,
),
- The tour doesn't wait for what the hooks return: a pushed route's future
only completes when it's popped. Instead, it waits for the next step's
target to appear on the top screen, up to
targetTimeout(one second by default), then for the screen's transition to finish. - Targets on screens covered by another route don't count.
- Keep the
PinpointToursomewhere that outlives the screens it visits, and bring the user back where you want inonFinishandonSkip.
Demo data
A new user often has nothing to show yet: an empty wallet, no orders. Show sample content while a tour runs, and the real content otherwise:
PinpointDemo(
demo: (context) => TransactionList(sampleTransactions),
child: TransactionList(transactions),
)
// Or, when most of the widget is the same:
PinpointDemo.builder(
builder: (context, touring) => BalanceCard(touring ? 1250 : balance),
)
- It switches as soon as the tour starts, and back when it finishes, is skipped or stops.
- It follows any running tour, or only the one you pass as
tour. - Targets inside the demo content work as usual: the tour waits for them.
- Outside widgets, for example in a repository or state manager,
PinpointTour.activetells you which tour is running.
Style and theme
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.
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'),
)
Example app
The example is a gallery of everything above: bubbles, placement and triggers, spotlight shapes and animations, a tour with every button option, and a multi-screen tour with demo data. A switch flips it all to right-to-left.
cd example && flutter run
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.
Migrating from 0.1.0
-
dismissOnTapOutsideis nowdismissible, on bothPinpointandPinpointTour. -
PinpointHighlight.spotlighttakes ashapeinstead ofborderRadius:// Before PinpointHighlight.spotlight(borderRadius: BorderRadius.circular(24)) // After PinpointHighlight.spotlight( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), ) -
computePinpointGeometryandPinpointGeometryare no longer exported. -
PinpointTour.isActiveis true fromstart()until the tour ends, including between steps, rather than only while a step shows.
License
MIT. See LICENSE.
Libraries
- pinpoint
- Anchored bubbles with a pointing arrow, spotlight highlighting, and guided tours.