Getting started
Install the two packages, run a first tour, and wire the provider to a real application.
guide walks a new user through an interface, one step at a time. The logic lives in one package and the Material UI rendering in another.
Two packages, and why
npm install @apollovisionlabs/guide-core @apollovisionlabs/guide-mui @mui/material @emotion/react @emotion/styled
@apollovisionlabs/guide-core holds the state machine, target resolution, route
matching, persistence and the accessibility primitives. It renders nothing and has
no runtime dependency beyond React.
@apollovisionlabs/guide-mui is one rendering of that engine: a spotlight overlay
and a step popover, built on Material UI. Its only runtime dependency is the core.
If you draw your own popover, install the core alone and read the active step from
useGuideStep(). Everything below assumes the Material UI layer, which is the
shortest path to a running tour.
The smallest thing that works
import { GuideProvider, useTour, type Tour } from '@apollovisionlabs/guide-core';
import { GuideTour } from '@apollovisionlabs/guide-mui';
const welcomeTour: Tour = {
id: 'welcome',
steps: [
{
target: 'nav.projects',
title: 'Your projects live here',
body: 'Everything you create is grouped under a project.',
placement: 'bottom',
},
],
};
function StartButton() {
const tour = useTour('welcome');
return <button onClick={() => tour.start()}>Start the tour</button>;
}
export function App() {
return (
<GuideProvider tours={[welcomeTour]}>
<nav>
<a href="/projects" data-guide="nav.projects">
Projects
</a>
</nav>
<StartButton />
<GuideTour />
</GuideProvider>
);
}
GuideProvider holds the state, GuideTour draws it. Both read the Material UI
theme, so keep them inside your ThemeProvider if you have one.
Declare the tour at module scope, as above. A tour object rebuilt inside a
component body is a new object on every render, and the missing target timeout is
keyed on step object identity: recreate the steps each render and that timer never
reaches its deadline. The symptom is a tour that hangs on a step forever, with
onMissingTarget apparently ignored. If a tour has to be built at runtime, build
it once in a useMemo with stable dependencies.
How a step finds its target
target is a logical key, not a CSS selector. The engine looks for
[data-guide="<key>"], so the highlighted element declares its own participation:
<button data-guide="projects.create">New project</button>
A CSS selector couples the tour to markup that changes for unrelated reasons: a
class renamed by a redesign, a wrapper added by a layout refactor, a generated
class name from a styling library. A data-guide attribute is a contract, visible
in the element’s own source, that a reviewer can see they are about to break.
Namespace the keys, as in nav.projects or projects.create. They appear in the
step:show and target:missing events, so they end up in whatever you log.
The element does not have to exist yet. The engine watches the DOM with a
MutationObserver for targetTimeoutMs (5000 by default), then applies the
missing target policy described in
Tours.
Wiring a real application
Five props turn the minimal example into something you can ship.
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { GuideProvider, createBrowserStorage } from '@apollovisionlabs/guide-core';
import { GuideTour } from '@apollovisionlabs/guide-mui';
import { productTour } from './tours';
const storage = createBrowserStorage('my-app');
export function App() {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
return (
<GuideProvider
tours={[productTour]}
navigate={(path) => navigate(path)}
location={location.pathname}
storage={storage}
translate={(key) => t(key)}
onEvent={(event) => console.info('[guide]', event)}
>
<AppRoutes />
<GuideTour />
</GuideProvider>
);
}
navigate is called when a step lives on another page. location is the current
pathname, which is how the engine knows whether it is already there. Omit
location and every step is treated as being on the right page, so nothing ever
navigates.
storage persists progress. Two implementations ship with the core:
createMemoryStorage() for tests, createBrowserStorage(namespace) for
localStorage. Neither talks to a server, and the packages make no network call of
their own. On a workstation several people share, localStorage belongs to the
browser profile and not to the person signed in, so the first person’s completed
tour suppresses it for the second. Where accounts share a machine, implement
GuideStorage against your own API.
translate resolves titleKey and bodyKey into text. It takes a key and returns
a string, so any translation library fits in one line. Set a key with no
translate and the raw key is displayed rather than a crash: that is the symptom
to recognise. The popover’s own buttons are separate, and are overridden through
labels on GuideTour:
<GuideTour
labels={{
next: t('common.next'),
previous: t('common.back'),
finish: t('common.finish'),
close: t('common.close'),
}}
/>
close is also the accessible name of the close button, so translating it is not
cosmetic. Checklist and ChecklistLauncher take their own labels in the same
way, covered in Checklist.
onEvent receives every GuideEvent: tour:start, tour:complete, tour:stop,
step:show and target:missing. The library sends nothing anywhere; what you
record and what consent it needs is your decision.
Peer requirements
React 19 for the core. React 19, Material UI 7 or 9, and Emotion 11 for the
Material UI layer. Both packages ship a 'use client' banner on every emitted
file, so importing them does not break a server build; GuideProvider still uses
state and context, so the component rendering it is a client component. GuideTour
returns null until it has mounted in the browser, which keeps the tour out of
server rendered HTML.