Tours
The shape of a tour and its steps, the missing target policy, multi page tours, interactive steps, advancing on a click and manual control.
A tour is data: an id and an ordered list of steps. There is no builder, no recorder and no admin screen. Tours are TypeScript objects in your repository, reviewed like any other code.
Tour and Step
interface Tour {
id: string;
steps: Step[];
}
id is what useTour and a checklist item’s tourId name. Two tours sharing an
id make GuideProvider throw at render, immediately, rather than picking one.
interface Step {
target: string;
route?: string;
navigateTo?: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
interactive?: boolean;
advanceOn?: 'click';
title?: string;
titleKey?: string;
body?: string;
bodyKey?: string;
onMissingTarget?: 'skip' | 'wait' | 'error';
}
target: the logical key carried by the element’sdata-guideattribute. The only required field.route: the pattern of the page this step belongs to. Accepts:paramsegments and*.navigateTo: the concrete path handed to yournavigatefunction.placement: which side of the target the popover sits on. Defaults tobottom.interactive: lets the user reach the page during the step. See below.advanceOn:'click'advances the step when the user clicks the target, instead of waiting for the popover’s button. See below.titleandbody: literal text.titleKeyandbodyKeyare resolved through the provider’stranslateinstead. A literal wins over a key when both are set, and a key with notranslateis displayed as is.onMissingTarget: overrides the provider’s policy for this step alone.
An empty steps array makes start() reject: a tour with nothing in it is a
mistake, not an empty run.
When the target is not there
The target may be absent when the step comes up: a slow request, a collapsed
panel, a feature this user does not have. The engine watches the DOM for
targetTimeoutMs (5000 by default), emits target:missing, then applies a
policy set on the provider and overridable per step.
| Policy | What the user sees |
|---|---|
wait (default) |
Nothing, for as long as the target is absent. The tour is paused, not dead: it resumes on its own the moment the element appears. Right when the element is late, wrong when it is never coming. |
skip |
The tour moves to the next step after the timeout. A pause, then the tour continues. Right for an optional step, or one tied to a feature not everyone has. |
error |
The tour stops. Right when the rest of the tour makes no sense without this step. |
The same timer covers a step whose route never matches, so a wrong pattern or a
failed navigation cannot leave a tour running and invisible forever.
While the engine waits, GuideTour draws nothing at all: no spinner, no dimmed
overlay, no popover. Drawing a spotlight with no hole in it over a page is worse
than drawing nothing. The tour is still running, and useTour(id).status says so,
so render your own feedback from it if a long wait is expected. One thing is
mounted during the wait: an Escape key handler, so the user is never stuck.
Tours that cross pages
The core knows nothing about your router. It reads location to decide whether
the current page already satisfies a step, and calls navigate when it does not.
{
target: 'project.share',
route: '/projects/:id',
navigateTo: '/projects/42',
title: 'Share it',
body: 'You were moved to this page automatically.',
}
route and navigateTo are different things, and confusing them is the usual
first mistake. route is a pattern, matched segment by segment against the
current pathname: :id matches any non empty segment, and * matches everything
from its position onwards. navigateTo is a literal path, the exact string handed
to navigate. A pattern cannot be navigated to, because :id is not a real path
segment.
When route contains neither : nor *, it is literal and doubles as the
destination, so navigateTo can be omitted. As soon as the pattern has a
parameter or a wildcard, navigateTo is required; without it the step has no
destination and the tour sits on the wrong page until the policy fires.
A step that declares a route while the provider has no navigate function logs
one warning and goes no further.
Interactive steps
A normal step is a demonstration. The overlay swallows clicks, so the highlighted element is not clickable: a click in the bright hole is ignored, a click outside it stops the tour. The SVG mask cuts the rendering of the overlay, not its hit area, which is why the button looks inert instead of reacting.
When the step asks the user to act, say so:
{
target: 'project.share',
interactive: true,
title: 'Share it',
body: 'Click the button yourself.',
placement: 'left',
}
interactive: true changes two things. The overlay becomes click through, so the
element receives the click. And the popover is rendered non modal: no focus trap,
no aria-modal.
That second change is deliberate. A modal dialog is a keyboard prison by design: Tab cycles inside it and never reaches the page. A step that tells someone to click a button while trapping their keyboard away from that button is asking for something they cannot do. An interactive step gives the keyboard back. The cost is that focus is no longer held, so the step is easier to lose track of. Use it for the steps that ask for an action, not as a default.
Setting interactive: true by hand and calling next() yourself once the user
has acted still works, and the next section covers that path. For the common
case, the step asks for one click on its own target, advanceOn below is the
better answer: it does what interactive plus a manual next() call would do,
without a handler of your own.
Advancing on an action
A step can declare advanceOn: 'click' instead of ending on the popover’s button:
{
target: 'project.share',
title: 'Share it',
body: 'Click the button yourself, this step is interactive.',
advanceOn: 'click',
}
The step advances when the user clicks the target, not the popover. advanceOn
implies interactive: a step that waits for a click has to let the click through,
so the core derives both interactive and awaitsAction on the active step from
advanceOn, rather than requiring you to set both by hand. A custom renderer
reads activeStep.interactive and activeStep.awaitsAction, not
step.interactive, which stays undefined on a step that only sets advanceOn.
GuideTour’s popover reflects awaitsAction: no primary button, a short label in
its place instead, and ArrowRight is ignored, since either would be a way around
the very thing the step is asking for. Back, Close and Escape still work.
The click listener is attached, in the bubble phase, to the element resolved when
the step opened, without preventDefault or stopPropagation, so your own click
handler on the target still runs.
If your application replaces that DOM node afterward, for instance by
re-rendering a list, the listener goes with it and the step stops advancing.
Nothing notices: the target was found once, so the timeout was already cleared,
no target:missing is emitted and no wait, skip or error policy runs. The
tour simply sits on that step. The consequence is specific to advanceOn, even
though the cause is not: an advanceOn step offers no primary button and ignores
ArrowRight, so a replaced node leaves the tour with Escape as its only exit. If
the element a step points at can be re-created under it, either give it a stable
target that survives the re-render or use an ordinary step with a Next button.
Driving a tour from your own code
import { useTour } from '@apollovisionlabs/guide-core';
function TourControls() {
const tour = useTour('product');
return (
<>
<button onClick={() => tour.start()}>Start</button>
<button onClick={() => tour.start({ from: 2 })}>Start at step three</button>
<button onClick={tour.previous}>Back</button>
<button onClick={tour.next}>Next</button>
<button onClick={tour.stop} disabled={tour.status === 'idle'}>
Stop
</button>
</>
);
}
useTour returns start, next, previous, stop, status and stepIndex.
status is idle, running, paused or completed, and reads idle whenever
another tour is the one running, so two components each watching their own tour
never see each other’s state.
There is no complete(). A tour completes when next() is called on its last
step, which is also when tour:complete is emitted. start returns a promise
because it may read persisted progress first; calling it again while the same tour
is already running is a no op, so a double click cannot rewind anyone.
The popover also answers the keyboard directly: arrow right advances, arrow left
goes back, Escape stops. Those keys are ignored while the user is typing in an
input, a textarea, a select or a contenteditable element.
Resuming
With a storage prop, the provider writes { status, stepIndex } under
tour:<id> on every advance and on completion, and reads it back when the tour
starts.
tour.start(); // resumes where the user left off
tour.start({ resume: false }); // always starts at step one
tour.start({ from: 2 }); // starts at the given index, ignoring storage
Only an in-progress record resumes. A tour recorded as completed starts again
from the beginning, which is what a user asking for the tour a second time
expects. A stored value is validated before it is trusted, so a hand edited or
stale entry falls back to a fresh start instead of throwing.
Storage that fails never blocks a tour. The read is caught, one warning is logged per provider, and the tour starts from step one.