APOLLO VISION LABS

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’s data-guide attribute. The only required field.
  • route: the pattern of the page this step belongs to. Accepts :param segments and *.
  • navigateTo: the concrete path handed to your navigate function.
  • placement: which side of the target the popover sits on. Defaults to bottom.
  • 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.
  • title and body: literal text. titleKey and bodyKey are resolved through the provider’s translate instead. A literal wins over a key when both are set, and a key with no translate is 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.