@go22/take-a-tour
v0.1.26
Published
Accessible, declarative product tours for React applications
Downloads
152
Maintainers
Readme
@go22/take-a-tour
An accessible, declarative product-tour engine for React. It can annotate an existing page or render application-owned shells with fixture data for pages a visitor cannot safely or normally access.
Release status:
0.1.10is published under thelatestnpm tag.
License
This project is licensed under the GNU General Public License, version 3 only
(GPL-3.0-only). See LICENSE.
Table of contents
- Install
- Initialize a project
- Included templates
- Basic tour
- Tour definition
- Provider options
- Starting on page load
- Launcher placement
- Programmatic control
- Tour UI and navigation
- Shells
- Step fields
- Selectors and element paths
- Page-owned real input
- Actions
- Validation
- Theme
- Accessibility
- Quick Start: single HTML page
- Quick Start: normal Vite application
- Development and testing
- Scope and routing
Install
Published version
npm install @go22/[email protected]Local package testing
Pack 0.1.10 from this package directory, then install the resulting archive in
the consuming project:
# In react-tour/
npm run check
npm pack --pack-destination /tmp
# In the consuming project
npm install --force --no-save --package-lock=false /tmp/go22-take-a-tour-0.1.10.tgzDo not install the package directory directly with npm install ../... npm
links a local directory instead of installing it like a registry package,
which can make an application load separate React instances and fail at
runtime.
React and React DOM 18 or newer are peer dependencies. Import the package CSS once in the application entry point:
import "@go22/take-a-tour/styles.css";Initialize a project
The take-a-tour-init executable creates or updates the integration files for
a standalone example:
package.json
index.html
main.jsx
vite.config.js
steps.jsRun the initializer from the npm registry:
npm exec --package @go22/take-a-tour take-a-tour-init
npm install
npm run buildAn existing valid package.json is merged with the required scripts and
dependencies. Existing main.jsx, vite.config.js, and steps.js files are
skipped. If index.html already exists, the matching sample page is written as
index_sample.html instead. Pass --force to replace generated integration
files intentionally:
npm exec --package @go22/take-a-tour take-a-tour-init --forceThe generated manifest includes npm run init, npm run dev, and
npm run build. The generated sample page and steps.js demonstrate uncounted
and counted structural paths plus page-owned real input. The Vite pre-hook
supplies a missing main.jsx module entrypoint during the build.
Included templates
The package contains two runnable templates under examples/:
examples/
|-- basic-react/ Normal Vite React application
`-- single-html/ Static page compiled to one self-contained HTML fileEach template has its own package.json and README. Copy a template outside
node_modules before adapting it because a later package install can replace
installed package contents.
Both examples keep the demonstrated markup ordinary:
<button type="button">Create project</button>The tour definition locates that element structurally and adds the selector it will use for spotlighting:
{
path: "body.main.button",
spotlightSelector: ".tour-create-project",
}Basic tour
Render the page without tour-specific classes or attributes:
export function Dashboard() {
return (
<main>
<h1>Dashboard</h1>
<button type="button">Create project</button>
</main>
);
}Define sequential steps in application code. This example assumes the React
application is mounted in a div under body:
import { defineTour } from "@go22/take-a-tour";
export const productTour = defineTour({
steps: [
{
path: "body.div.main.h1",
title: "Your dashboard",
body: "Active projects and recent activity appear here.",
auto: { dwellMs: 5000 },
interactive: { waitFor: "next-click" },
},
{
path: "body.div.main.button",
title: "Create a project",
body: "Start a new project from this button.",
auto: { dwellMs: 5000, holdAtEnd: true },
interactive: { waitFor: "next-click" },
},
],
});Mount one provider. It supplies the portal and tour UI; no separate tour root is needed:
import { TourEntryButton, TourProvider } from "@go22/take-a-tour";
import { productTour } from "./tour.js";
export function App() {
return (
<TourProvider tour={productTour}>
<Dashboard />
<TourEntryButton>Take a tour</TourEntryButton>
</TourProvider>
);
}Tour definition
defineTour() provides generic type checking for shell names and step data and
returns the public TourConfig shape.
| Option | Default | Purpose |
|---|---|---|
| steps | required | Ordered, non-empty array of TourStep objects |
| shells | none | Map of shell names to React shell components |
| remountOnStepChange | [] | Shell names whose component instance is keyed by step index |
| defaultCadence | 1 | Initial timing multiplier from 0.5 through 2 |
| defaultMode | "auto" | Mode selected by start() and initially selected on the start screen |
| colorScheme | "default" | "default" or "random" provider accent palette |
| ttloc | none | CSS selector that relocates a rendered TourEntryButton |
| startOnLoad | none | "default", "auto", or "interactive" mount behavior |
| labels | built-in English labels | Partial overrides for all public UI labels |
| getStepAnnouncement | built-in step announcement | Generates screen-reader text from step, position, and total |
Step sequence and identity come from array order. A TourStep needs no id or
key; use remountOnStepChange when a shell must remount between steps.
Provider options
| Prop | Required | Purpose |
|---|---|---|
| tour | yes | The result of defineTour() |
| children | yes | The application or demonstrated content |
| portalContainer | no | Portal destination; defaults to document.body |
| className | no | Additional class on the active .rt-canvas |
Changing portalContainer changes where the tour canvas is portaled, not which
document is searched for selectors. useTour() must be called below the
provider.
Starting on page load
Set startOnLoad when the provider should launch the tour on its first mount:
const tour = defineTour({
startOnLoad: "default",
steps,
});| Value | Page-load behavior |
|---|---|
| "default" | Opens the start screen so the user can choose mode and pace |
| "auto" | Skips the start screen and starts Auto mode |
| "interactive" | Skips the start screen and starts Interactive mode |
Omit it to wait for TourEntryButton, open(), or start(). An entry button
can remain on an auto-starting page so the user can replay the tour.
Launcher placement
TourEntryButton normally renders in a fixed surface at the lower right. Set
ttloc to portal that same rendered button into the first matching element:
const tour = defineTour({
ttloc: "main",
steps,
});A relocated button is an unenclosed, underlined Take-a-Tour control. Missing,
empty, malformed, or unmatched selectors, including the string "null", use
the lower-right fallback. Void elements such as input and img cannot receive
the button and also use the fallback. ttloc only relocates a rendered
TourEntryButton; it does not create one, resolve paths, or affect step
targeting.
Programmatic control
useTour() exposes a stable controller for custom launchers and controls:
import { useTour } from "@go22/take-a-tour";
function HelpButton() {
const tour = useTour();
return (
<button onClick={() => tour.start({ mode: "interactive", stepIndex: 1 })}>
Help
</button>
);
}The state fields are active, configuring, currentStepIndex, mode,
cadence, and playing. Methods are open, start, close, next,
previous, goTo, setMode, setCadence, pause, and resume.
open() shows the start screen. start() begins at its requested zero-based
stepIndex, or the first step, with optional mode and cadence. Controller next()
closes the tour when called on the final step. Controller previous() does
nothing on the first step. goTo() ignores invalid indexes and pauses timed
playback. setCadence() clamps values to 0.5 through 2.
Tour UI and navigation
There is no separate top bar. The current step appears in one explainer card
positioned to avoid the spotlight when possible. Its upper completion row
contains the step title plus a reciprocal Auto/Interactive mode link, Restart,
and explicit Exit controls. The message sits below it in a centered, rounded
one-pixel bordered panel with font-weight: 500.
In Interactive mode, the card's lower action row contains Prev, Contents,
and Next. Prev is disabled on the first step, Next is disabled on the final
step, and neither control wraps. Exit closes the interactive tour. Contents
opens the step list and selecting an item pauses playback. Prev and Next use
the active accent; Contents uses #7C3AED.
In Auto mode, the lower area contains pause/resume and pace controls instead of
step buttons. Pace appears beside a centered half-width slider below the
pause/resume control. A vertical countdown rail shrinks beside the message.
Timed advance calls controller next(), so it closes after the final step
unless the final step uses holdAtEnd. Switching modes preserves the current
step.
Shells
Shells are optional application components for authenticated screens, controlled fixture states, or interactions that must not reach a live backend. They receive the active step and render inside the tour canvas.
import { defineTour, type TourShellProps } from "@go22/take-a-tour";
type Shell = "dashboard";
type StepData = { state: "empty" | "populated" };
function DashboardShell({ step }: TourShellProps<Shell, StepData>) {
return <Dashboard projects={fixtures[step.data?.state ?? "empty"]} readOnly />;
}
export const productTour = defineTour<Shell, StepData>({
steps: [
{
title: "Project progress",
body: "Completed work is summarized here.",
shell: "dashboard",
data: { state: "populated" },
spotlightSelector: ".tour-project-progress",
auto: { dwellMs: 5000, holdAtEnd: true },
interactive: { waitFor: "next-click" },
},
],
shells: { dashboard: DashboardShell },
});List a shell in remountOnStepChange only when its internal state must be
re-created for every step. Omit it when state should persist across consecutive
steps. Shell-backed controls remain inside the normal modal canvas.
Step fields
| Field | Required | Purpose |
|---|---|---|
| title | yes | Non-empty heading and announcement copy |
| body | yes | Non-empty explainer and announcement copy |
| shell | no | Name of a registered shell component |
| data | no | Opaque payload passed to the shell in step |
| path | no | Structural path whose resolved element is highlighted and used for interaction |
| spotlightSelector | no | Alternative selector target, or an optional annotation applied to a path target |
| hideExplainer | no | Hides the card for a self-contained shell |
| auto.dwellMs | yes | Positive base duration before timed advance |
| auto.script | no | String value applied to the resolved target 700ms after the step starts |
| auto.holdAtEnd | no | Runs scripts but prevents timed advance |
| auto.playScriptInInteractive | no | Runs the timed script in Interactive mode |
| interactive.waitFor | yes | "next-click" or "real-input" |
| interactive.interactionSelector | no | Override that expands or changes the page interaction opening |
| interactive.event | no | Overrides the inferred click, input, or change event |
Selectors and element paths
Every CSS selector target is obtained with document.querySelector() and
therefore uses the first match. Structural paths use their own resolver and the
resolved element is measured directly; no selector is required. Make every
explicit selector unique.
A path contains dot-separated standard HTML tags. Tags are case-normalized to
lowercase. An optional suffix is a one-based index among elements of that tag:
section2 is the second section, while h25 is the fifth h2. The first
segment is selected in document order. Every later segment selects only among
the current element's direct children. An omitted index means 1.
{
path: "body.main.section2.h2",
}Only standard tags recognized by the package are accepted; custom-element names are not supported. Empty segments, unknown tags, zero or unsafe indexes, and unresolved direct-child paths fail silently.
When both path and spotlightSelector are present, the provider can add only
a simple .class, [attribute], or
[attribute="value"] selector to the element. A complex selector is not
synthesized, although it still works if the existing markup already matches it.
Omit path to target any selector already present in the page.
Path annotations run in a provider layout effect after React commits and rerun
when the tour.steps array identity changes. Targets that mount later are not
automatically retried unless that dependency changes. Added classes and
attributes are not removed when a step changes or the provider unmounts.
validateTour() does not parse, resolve, or otherwise check path values.
Page-owned real input
A page-annotation step can require a real interaction with existing page UI:
interactive: {
waitFor: "real-input",
}In Interactive mode, the resolved path element determines the opening through
which the page can receive pointer input. Four transparent shield panes cover
the areas above, below, left, and right of that opening. The rest of the page
remains blocked. Without a path, spotlightSelector is used instead.
The event is inferred from the target: text inputs and textareas use input;
selects, checkboxes, radio buttons, and file inputs use change; buttons,
links, and other elements use click. Set interactive.event only to override
that behavior. Set interactive.interactionSelector when the interaction
opening must differ from the path target, such as exposing a card containing
both an input and its Submit button.
The external target, or its first focusable descendant, receives initial focus
and joins the card controls in the focus loop. Because focus and interaction
intentionally cross the dialog boundary, aria-modal is omitted for this
state. Page-owned input behavior applies only to steps without a shell.
The inferred or configured event is captured on document; an event target
inside the interaction opening satisfies the gate. It enables Next but does not
automatically advance. Until the event occurs, Next remains disabled. If the
target is missing, has no measurable rectangle, or the event
never fires, page access does not open and the step remains gated. Use an
explicit Exit or Escape to leave that state.
Actions
auto.script is a single string applied to the resolved path or spotlight
target 700ms after the step starts. The delay is fixed and has no step option.
The target element determines the operation:
- Text, number, date, time, range, color, and similar inputs receive the script
as their value and dispatch
inputandchange. - Textareas and editable content receive the script as text.
- A checkbox accepts
"select"or"deselect". - A radio input accepts a value from its named group or
"sequence". - A select accepts an option value or
"sequence". - A button, clickable input, link, or other clickable element accepts
"click". - File inputs cannot be populated by scripts.
Radio and select sequences begin at 700ms and move through choices every 500ms,
stopping before the current paced dwell expires. The last choice remains
selected. Scripts run during active Auto playback and can also run in
Interactive mode when playScriptInInteractive is true.
auto: { dwellMs: 5000, script: "Apollo" }auto: { dwellMs: 5000, script: "click" }"click" performs a real DOM click. Navigation, form submission, application
state changes, and any other resulting effects are intentional and are the
responsibility of the developer authoring the step.
Validation
Use validateTour() in consuming-application tests:
import { validateTour } from "@go22/take-a-tour";
import { productTour } from "./tour.js";
expect(validateTour(productTour)).toEqual([]);It reports an empty tour; blank title or body; unregistered shells; non-finite
or non-positive dwell times; scripts whose dwell is not greater than 700ms;
empty spotlight or interaction selectors; defaultCadence outside 0.5
through 2; and unsupported startOnLoad or colorScheme values.
Validation is structural. It does not test CSS selector syntax or whether a
selector matches the document. It does not inspect path, require a spotlight
selector, validate script compatibility with a target, or confirm runtime page and shell
behavior. An empty tour returns only the empty-tour issue.
Theme
The exact stylesheet defaults are:
:root {
--rt-accent: #b45309;
--rt-accent-hover: #92400e;
--rt-accent-soft: #fffbeb;
--rt-on-accent: #ffffff;
--rt-surface: #ffffff;
--rt-surface-muted: #f1f5f9;
--rt-text: #334155;
--rt-text-muted: #64748b;
--rt-border: #cbd5e1;
--rt-overlay: rgba(15, 23, 42, 0.52);
--rt-spotlight: rgba(251, 191, 36, 0.86);
--rt-progress: var(--rt-accent);
--rt-radius: 0.65rem;
--rt-font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--rt-z-index: 3000;
}Pass className to TourProvider to target one active canvas. All package
classes use the rt- prefix.
At provider initialization, colorScheme: "default" chooses #B45309 and
"random" chooses one of 12 predefined accents. The provider derives hover,
soft, and contrasting foreground colors, then writes --rt-accent,
--rt-accent-hover, --rt-accent-soft, --rt-on-accent, and --rt-progress
as inline styles on the canvas. Those inline palette values override ordinary
class or root declarations. Override them with inline-style priority such as
!important, or theme the remaining variables normally. The palette is chosen
once for each provider mount, not on every tour start.
Accessibility
The package provides Escape handling, focus restoration, dynamic focus
discovery, step announcements, a button-list Contents panel, and keyboard
focus containment. The start screen exposes mode and pace choices. Interactive
page-owned input intentionally expands the focus loop and omits aria-modal as
described above.
Auto mode provides pause/resume, a pace slider, and a shrinking countdown rail so timed transitions are predictable. The rail pauses with playback and is omitted in Interactive mode and on held steps. Do not remove pause from a customized Auto experience. Keep unavailable shell controls genuinely disabled so they do not enter the dialog's Tab cycle.
Quick Start: single HTML page
examples/single-html is the canonical static-page workflow. It leaves the
demonstrated page as ordinary HTML and mounts React only for the provider and
entry button. Vite and vite-plugin-singlefile compile JavaScript and CSS into
one dist/index.html. The tour must run in the same document as path targets;
an iframe cannot inspect elements in its parent document.
Use these root-level files:
index.html
main.jsx
steps.js
vite.config.js
package.jsonThe source index.html contains the static page only. It needs neither a React
mount placeholder nor a module script because the Vite pre-hook injects the
entry point:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Single-file product tour</title>
</head>
<body>
<main>
<h1>Example dashboard</h1>
<section>
<h2>Recent activity</h2>
<p>Two projects were updated today.</p>
</section>
<section>
<h2>Project summary</h2>
<button type="button">Create project</button>
</section>
<div><h2>Saved views</h2></div>
<div id="project-submission">
<label for="project-name">Project name</label>
<input id="project-name" type="text" />
<button id="project-submit" type="button">Submit project</button>
<p id="project-result" aria-live="polite" hidden></p>
</div>
</main>
<script>
const projectName = document.querySelector("#project-name");
const projectResult = document.querySelector("#project-result");
function submitProject() {
const name = projectName.value.trim();
if (!name) return projectName.focus();
projectResult.textContent = `Project submitted: ${name}`;
projectResult.hidden = false;
projectName.value = "";
}
document.querySelector("#project-submit").addEventListener("click", submitProject);
projectName.addEventListener("keydown", (event) => {
if (event.key === "Enter") submitProject();
});
</script>
</body>
</html>Define paths against that static structure in steps.js. An omitted count
selects the first matching tag, while section2 and div2 select the second
direct child of each tag type:
export const startOnLoad = "default";
export const colorScheme = "random";
export const ttloc = null;
export const steps = [
{
path: "body.main.section",
title: "Recent activity",
body: "A path without a count selects the first matching section.",
auto: { dwellMs: 5000 },
interactive: { waitFor: "next-click" },
},
{
path: "body.main.section2.button",
title: "Create a project",
body: "The second section contains the control for starting a project.",
auto: { dwellMs: 5000 },
interactive: { waitFor: "next-click" },
},
{
path: "body.main.div2.input",
title: "Name your project",
body: "Enter a project name, then submit it below.",
auto: { dwellMs: 5000, script: "Apollo" },
interactive: {
waitFor: "real-input",
interactionSelector: "#project-submission",
},
},
{
path: "body.main.div2.button",
title: "Submit your project",
body: "Submit the entered project name.",
auto: { dwellMs: 5000, script: "click" },
interactive: { waitFor: "real-input" },
},
];Root main.jsx creates its mount dynamically:
import React from "react";
import { createRoot } from "react-dom/client";
import { TourEntryButton, TourProvider, defineTour } from "@go22/take-a-tour";
import "@go22/take-a-tour/styles.css";
import * as settings from "./steps";
const tour = defineTour(settings);
const mount = document.body.appendChild(document.createElement("div"));
createRoot(mount).render(
<React.StrictMode>
<TourProvider tour={tour}>
<TourEntryButton>Take a tour</TourEntryButton>
</TourProvider>
</React.StrictMode>,
);The ensure-main-entrypoint pre-hook avoids duplicate injection, inserts the
root entry before </body>, and appends it when an input document has no closing
body tag:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const ensureMainEntrypoint = {
name: "ensure-main-entrypoint",
transformIndexHtml: {
order: "pre",
handler(html) {
if (/<script\b[^>]*\bsrc\s*=\s*(["'])\.\/main\.jsx\1[^>]*>/i.test(html)) return html;
const entrypoint = ' <script type="module" src="./main.jsx"></script>\n';
return /<\/body>/i.test(html)
? html.replace(/<\/body>/i, `${entrypoint}</body>`)
: `${html}\n${entrypoint}`;
},
},
};
export default defineConfig({
plugins: [ensureMainEntrypoint, react(), viteSingleFile()],
build: {
cssCodeSplit: false,
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
},
});Install the package using the command in Install, then run:
npm run buildThe result is dist/index.html, which can be opened with a file:// URL or
served as one static file. Links, remote images, fonts, network requests, and
CSS URLs remain external unless imported and inlined by the build. A strict
Content Security Policy must permit the generated inline script and style,
usually with hashes or nonces.
Quick Start: normal Vite application
For a normal application, mount the page and provider together in the existing
React entry point. index.html has a root and a normal Vite module script:
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>import React from "react";
import { createRoot } from "react-dom/client";
import { TourEntryButton, TourProvider, defineTour } from "@go22/take-a-tour";
import "@go22/take-a-tour/styles.css";
import App from "./App.jsx";
import { steps } from "./steps.js";
const tour = defineTour({ startOnLoad: "default", steps });
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<TourProvider tour={tour}>
<App />
<TourEntryButton>Take a tour</TourEntryButton>
</TourProvider>
</React.StrictMode>,
);Because the application is mounted under #root, paths to application markup
begin with body.div, for example body.div.main.h1. A normal Vite build does
not use vite-plugin-singlefile; deploy all of dist/, including generated
JavaScript and CSS assets.
Development and testing
The current development package version is 0.1.10. From the package directory:
npm run typecheck
npm test
npm run build
npm run check
npm run build:examplenpm run check runs type checking, the current 32-test Vitest suite, and the
package build. npm run build:example regenerates ../example.html; review that
generated output when the example source changes. Use
--package-lock=false when installing a temporary packed archive into an
example so local verification does not rewrite its lockfile.
Scope and routing
This release supports one mounted document plus application-owned shells. It does not navigate routes or HTML pages. Coordinate routing in application code before starting the relevant tour, or render route states as shells. For a true multiple-HTML Vite build, configure every HTML file as a Vite input and mount or import the integration on each page; each step target must exist in the current document or in the active shell.
