# Hintbeam — full documentation
> Open-source product tours for React, Next.js and React Native. Tours point at named targets, not CSS selectors, so they survive refactors and page changes.
# Getting started
Five minutes from install to your first tour. Pick your platform; the steps are the same everywhere.
## Using an AI coding agent?
Paste this into Claude Code, Cursor, Copilot or any coding agent. It reads the full documentation
and does the steps on this page for you.
```text
Add a product tour to this app with Hintbeam (https://hintbeam.js.org). First download https://hintbeam.js.org/llms-full.txt and read all of it (for example with curl; a summarised fetch leaves out details you need). It is the complete documentation. Then:
1. Detect the framework (Next.js App Router, React Router, Expo Router, React Navigation or plain React) and install `hintbeam`.
2. Follow "Getting started" for that framework: list the targets with `defineTargets` in `tours.ts`, add the `declare module "hintbeam"` Register block, and wrap the app in `TourProvider` with the matching router adapter and storage (`browserTourStorage()` on the web, `createTourStorage(AsyncStorage)` on React Native).
3. Ask me which flow to guide, or propose a 3–5 step first-run tour of the main screens.
4. Attach `useTarget("name")` to those elements. Never use CSS selectors. In the Next.js App Router, keep pages as Server Components and put the tagged elements in small "use client" components; import `tours.ts` only from client files.
5. Write the tour with `defineTour`, and start it with `useTour().startOnce(tour)` from a client component rendered inside `TourProvider`.
6. Run the type check, the build and the tests (say so if the project has none), then show me the diff.
```
## 1. Install
```sh
npm install hintbeam
```
React Native / Expo also needs `react-native-svg` (Expo: `npx expo install react-native-svg`).
`import { … } from "hintbeam"` gives you the web build in browsers and the native build in React
Native automatically. You never import a platform by hand.
## 2. Say what can be pointed at
Make one file, `tours.ts`, and list the things a tour may point at. These are **targets**.
(In Next.js, put it in `app/`: `app/tours.ts`.)
```ts
import { defineTargets, defineTour } from "hintbeam";
export const targets = defineTargets({
search: { screen: "/food", about: "The food search box" },
logMeal: { screen: "/food", about: "The button that logs a meal" },
weight: { screen: "/progress", about: "This week's weight chart" },
helpIcon: { about: "The ? in the header, on every page" },
});
// Lets TypeScript check every useTarget("…") name against this list.
declare module "hintbeam" {
interface Register {
targets: typeof targets;
}
}
```
- `screen` is the page the target lives on, written the way your router writes it (`"/food"` in
Next.js, React Router and Expo Router; `"Food"` in React Navigation).
- Leave `screen` out for things shown on every page — a header icon, a tab bar.
- `about` is a note for whoever writes tours. Users never see it.
## 3. Write a tour
```ts
export const firstMeal = defineTour(targets, {
id: "first-meal",
steps: [
{ target: "search", title: "Find food", text: "Search anything you ate." },
{ target: "logMeal", text: "Log it with one tap.", advanceOn: "tap" },
{ target: "weight", text: "Your progress lives here." },
],
});
```
A misspelt target is a **compile error**, in tours and in `useTarget`, and a "did you mean…?" error
at runtime.
## 4. Wrap your app once
### React (Vite, CRA) with React Router
```tsx
import { TourProvider } from "hintbeam";
import { useReactRouter } from "hintbeam/react-router";
import { targets } from "./tours";
function Tours({ children }) {
return {children};
}
// Inside your / tree:
```
### Next.js (App Router)
```tsx
// app/tour-provider.tsx
"use client";
import { TourProvider, browserTourStorage } from "hintbeam";
import { useNextRouter } from "hintbeam/next";
import { targets } from "./tours";
// Remembers who finished or skipped a tour. Safe during server rendering.
const storage = browserTourStorage();
export function Tours({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// app/layout.tsx (stays a Server Component)
{children}
```
**Server Components.** Hintbeam runs in the browser: `TourProvider`, `useTarget`, `useScreenLink`
and `useTour` work in files marked `"use client"`. Keep your pages as Server Components and move just
the elements a tour points at into small client components (step 5). Import `tours.ts` only from
client files. To check tours on a server instead, import `defineTargets`, `defineTour` and
`parseTour` from `hintbeam/core`, which has no React.
### Expo Router
```tsx
// app/_layout.tsx
import { Stack } from "expo-router";
import { TourProvider } from "hintbeam";
import { useExpoRouter } from "hintbeam/expo-router";
import { targets } from "../tours";
export default function Root() {
return (
);
}
```
### React Navigation
```tsx
import { NavigationContainer, createNavigationContainerRef } from "@react-navigation/native";
import { TourProvider } from "hintbeam";
import { useReactNavigation } from "hintbeam/react-navigation";
const navigationRef = createNavigationContainerRef();
export default function App() {
return (
{/* your navigator */}
);
}
```
No router? Leave `router` out — everything is treated as one screen.
## 5. Tag the elements
Attach a ref to something you already render. Nothing is wrapped, nothing moves.
```tsx
import { useTarget } from "hintbeam";
function FoodPage() {
const search = useTarget("search");
const logMeal = useTarget("logMeal");
return (
<>
{/* web */}
>
);
}
// React Native: ,
```
In the Next.js App Router, make that a small client component and use it in your page:
```tsx
// app/food/search-box.tsx
"use client";
import { useTarget } from "hintbeam";
export function SearchBox() {
return ;
}
```
A link, tab or menu item that leads to another page can be tagged too, so a step whose target is
there can point at the way in: `Food`.
## 6. Start it
```tsx
"use client";
import { useTour } from "hintbeam";
import { firstMeal } from "./tours";
export function HelpMenu() {
const { start } = useTour();
return ;
}
```
That's a working tour. When a step's target is on another page, the card offers **Take me there**.
When the target is scrolled out of view, it offers **Show me**. When it can't be found, the card says
so and lets people carry on, rather than pointing at empty space.
To show it on someone's first visit, call `startOnce`. It plays the tour only if they have never
finished or skipped it, and it waits for saved progress before deciding, so it's right on page load
with any `storage`. Call it from a component rendered inside `TourProvider` (in Next.js, a client
component), on the page where the tour begins:
```tsx
"use client";
import { useEffect } from "react";
import { useTour } from "hintbeam";
import { firstMeal } from "./tours";
export function FirstVisitTour() {
const { startOnce } = useTour();
useEffect(() => {
void startOnce(firstMeal);
}, [startOnce]);
return null;
}
```
Pass `storage` to `TourProvider` (see [Concepts](https://hintbeam.js.org/docs/concepts)) so this survives a reload.
## 7. Make it look like your product
```tsx
import { TourProvider, createTourTheme } from "hintbeam";
const theme = createTourTheme({ brand: "#E5484D", mode: "light", style: "balanced" });
```
`style` is one of `"aurora"`, `"balanced"`, `"subtle"` or `"minimal"`, from most to least going on.
[Customising](https://hintbeam.js.org/docs/customising) covers every option.
## Testing your app
Hintbeam is published as ES modules. Vitest, Vite, Next.js and Metro read them as they are, so
there is nothing to set up. Jest transforms only your own code by default, so tell it to transform
`hintbeam` too:
```js
// jest.config.js
module.exports = {
// …your config
transformIgnorePatterns: ["/node_modules/(?!hintbeam/)"],
};
```
With a preset that already sets this list (`jest-expo`, `react-native`), add `hintbeam` to the
names inside its `(?!…)` group instead of replacing it.
## Next
- [Concepts](https://hintbeam.js.org/docs/concepts) — how targets, steps and screens fit together
- [Customising](https://hintbeam.js.org/docs/customising) — brand colours and styles, words and translations, line styles, your own card
- [Routers](https://hintbeam.js.org/docs/routers) — every router, and how to plug in your own
- [Plugins](https://hintbeam.js.org/docs/plugins) — analytics, targeting and add-ons
- [API reference](https://hintbeam.js.org/docs/api)
---
# Concepts
Four words carry the whole library: **target**, **tour**, **step**, **screen**.
## Targets
A target is a *name* for something a tour can point at, declared once with `defineTargets`. The
element is attached later, wherever it is rendered, with `useTarget(name)`. An element that renders
late (after data loads, behind a toggle) is picked up the moment it mounts, even while its step is
already showing.
Keeping names separate from elements is what makes the rest possible:
- **Tours are data.** A step says `target: "search"`, not "the third button in the header". Tours
survive redesigns, and can be stored as JSON, edited in a CMS, or drafted by an AI.
- **Typos are caught.** In TypeScript a wrong name does not compile; in JSON it fails validation with
a "did you mean…?" hint.
- **One name, many elements.** A phone tab bar and a desktop sidebar can both carry `"reports"`.
Whichever is actually on screen is used, so one tour works at every size.
- **One element, many names.** `useTarget(["search", "firstField"])`.
## Tours and steps
A tour is `{ id, steps }`, with 1–8 steps. Each step has a `target`, `text`, an optional `title`, and
an optional `advanceOn` — how it ends:
| `advanceOn` | The step ends when… |
| --- | --- |
| `"next"` *(default)* | the user taps Next |
| `"tap"` | the user taps the highlighted element itself |
| `"arrive"` | the user reaches the screen the target is on |
| `{ event: "report.exported", timeout: 30 }` | your app calls `emit("report.exported")`. After `timeout` seconds (default 30) Next appears anyway, so nobody gets stuck |
Text is plain words on one line (a title of 2–60 characters, text of 4–160). That keeps steps short —
users skip long tours — and makes JSON from anywhere safe to show.
## Screens
A screen is a page, named the way your router names it. Each target says which screen it lives on.
The router adapter tells the tour which screen is in front and how to get to another one.
## Finding the target — never pointing at nothing
Every time a step shows, and every time something moves (a navigation, a scroll settling, a resize),
the tour **measures** where its target is. It never remembers a position. There are four outcomes:
| Where | What the user sees |
| --- | --- |
| **visible** — on this screen, in view | The light and ring point at it |
| **offscreen** — on this screen, scrolled away | A **Show me** button scrolls it into view |
| **otherScreen** — on a different screen | The link to that screen is highlighted, with **Take me there** |
| **notFound** — not on screen anywhere | The card says so and offers Next. It never points at nothing |
The page itself is the scroll area on the web. For content that scrolls inside its own container (and
for React Native's `ScrollView`), use `useTourScroll(screen)` so "Show me" can scroll it.
## The tour is a companion, not a modal
The card never covers or blocks your app. Users can keep using the real screen — tapping the
highlighted button *is* the step, in a `"tap"` step. Skip closes the tour and remembers the step, so
`resume(tour)` continues later; `stop()` closes without remembering.
## Progress
The provider remembers, per tour, the step a user reached, whether they skipped, when they first
finished, and how many times they played it. Pass `storage` to keep it across reloads:
```ts
// Web: localStorage, safe in server rendering (Next.js). Create it once, outside render.
import { browserTourStorage } from "hintbeam";
const storage = browserTourStorage();
// React Native, or any store with getItem and setItem (MMKV, your own API)
import { createTourStorage } from "hintbeam";
const storage = createTourStorage(AsyncStorage);
```
Saved progress loads asynchronously. `startOnce(tour)` waits for it before deciding, so use it for
a first-visit tour rather than checking `hasCompleted` on the first render.
`mergeTourProgress(a, b)` joins two copies (two devices) safely — it is commutative, associative and
idempotent — for when you sync progress through your own backend.
## Layers
```
hintbeam/core the engine: tours, validation, finding, the player, progress, paths, plugins
│ (no React — use it on a server, in a CMS, in CI)
hintbeam (React) TourProvider, useTarget, useTour, useTourStep
├── web React DOM, Next.js, Vite, Expo web
└── native React Native, Expo
routers next · react-router · expo-router · react-navigation · your own
plugins analytics · targeting · path styles · add-ons
```
See [Architecture](https://hintbeam.js.org/docs/architecture) for how to add a platform, a router or a renderer.
---
# Customising
## Brand colour and style — the quick way
```tsx
import { TourProvider, createTourTheme } from "hintbeam";
```
- **`brand`:** your colour. The light, the highlight and the main button are made from it. The
light blends into a neighbouring hue; pass `brand2` to choose the second colour, or the same colour
for a flat look. Button text turns dark on light brand colours.
- **`mode`:** `"light"` or `"dark"` cards.
- **`style`:** how much is going on.
| `style` | Looks like | For |
| --- | --- | --- |
| `"aurora"` | Three strands of light, the guide, a soft glow and spotlight | Launch moments, first-run tours |
| `"balanced"` *(default)* | The guide and a single wave, softly lit | Most apps |
| `"subtle"` | A thin straight line and a quiet card; no guide; nothing keeps moving | Busy, serious or data-heavy apps |
| `"minimal"` | An outline around the target and a card beside it, like a tooltip; no light or dimming | When the tour should barely be noticed |
Change anything else with `overrides`:
`createTourTheme({ brand, style: "subtle", overrides: { radius: 8, glow: 0.3 } })`. The
[playground](https://hintbeam.js.org/playground) has every control, and writes this code for you.
## Theme
```tsx
import { TourProvider, TOUR_THEMES } from "hintbeam";
```
| Key | Default | |
| --- | --- | --- |
| `accent`, `accent2` | violet, sky blue | The light blends from `accent` to `accent2`; the main button too. Set only `accent` for a flat look |
| `onAccent` | `#FFFFFF` | Text on the main button |
| `card`, `border`, `text`, `mutedText` | frosted white, hairline, near-black, grey | The card. A translucent `card` gives frosted glass on the web |
| `radius` | `16` | Card corners |
| `inset` | `{ top: 56, bottom: 32, horizontal: 16 }` | Distance from screen edges — set `top` below a sticky header |
| `maxCardWidth` | `360` | On tablets and desktops |
| `fontFamily` | your page's font | |
| `light` | `true` | `false`: highlight only, no light |
| `guide` | `true` | The guide on the card's edge (below). `false` hides it |
| `backdrop` | `0.2` (`0.35` dark) | How much the rest of the screen dims around the target, 0–1. `0` turns the spotlight off. Nothing is ever blocked |
| `glow` | `0.4` | One dial for every glow — the light, the highlight, the card and the guide. `0` is flat |
| `motion` | `"full"` | `"full"`: words arrive one by one, a spark keeps travelling, the target ripples. `"calm"`: the light draws once and everything else stays still. `"none"`: no motion (also what anyone with reduced motion gets) |
| `path` | — | The light style when `TourProvider` has no `path` prop |
| `zIndex` | `2147483000` | Web only |
## Where the card goes
Next to its target: below it when there is room, then above, right, left. When none fits — and
always on phones narrower than 640 — the card docks to the screen edge away from the target. For a
target scrolled out of view it docks *toward* it, and the light runs to the edge the target is past,
with a glow and a chevron there. While anything scrolls, the card, the light and the highlight
follow the target every frame. The placement is one pure function, `placeStep`, shared by web and
native.
## The guide
Every card has a small living light on the edge facing its target — the guide. The light of each
step leaves from it, and it shows its mood: it **leans toward** what it points at, **breathes faster**
while a step waits for the user, and **searches** (dimmer, ring spinning) when the target is on
another page or not found. Turn it off with `theme={{ guide: false }}`.
Use it elsewhere — a help button, an empty state — with `GuideOrb`:
```tsx
import { GuideOrb } from "hintbeam";
```
## Words and translations
Every word users read comes from `labels`:
```tsx
`Paso ${n} de ${total}`,
}}
/>
```
`announce(title, text, n, total)` controls what screen readers hear when a step appears.
Tour text itself is your data — keep one tour per language, or build tours from your i18n strings.
## Path styles
How the light travels from the guide to the target:
| `path` | Looks like |
| --- | --- |
| `"wave"` *(default)* | A soft S-curve that leaves with a swing and arrives from the side |
| `"strands"` | Three lines of light: the wave, with two fainter strands fanned out beside it that gather at the target — the most magical |
| `"straight"` | The shortest line — calm, clearest on busy screens |
| `"elbow"` | Down, then across, with a rounded corner — reads like a diagram |
| `"arc"` | One generous arc — playful, good over long distances |
```tsx
```
### Your own path style
A style is a pure function from a start point and a target box to a route. `routeThrough` turns
Bézier segments into a route with evenly spaced points, so a new style is a few lines:
```ts
import { defineTourPath } from "hintbeam";
import { nearEdge, routeThrough, type Point } from "hintbeam/core";
export const zigzag = defineTourPath((from, to, options) => {
const end = nearEdge(from, to);
const mid = { x: (from.x + end.x) / 2 + 40, y: (from.y + end.y) / 2 };
const line = (a: Point, b: Point): [Point, Point, Point] => [
{ x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 },
{ x: a.x + (2 * (b.x - a.x)) / 3, y: a.y + (2 * (b.y - a.y)) / 3 },
b,
];
return routeThrough(from, [line(from, mid), line(mid, end)], options?.samples);
});
```
Every renderer draws every style, on every platform — styles never touch the screen.
## Your own step card
Render anything with `renderStep`:
```tsx
(
{step.step?.title}
{step.step?.text}
{step.primary === "takeMeThere" && }
{step.primary === "showMe" && }
{(step.primary === "next" || step.primary === "done") && }
)}
/>
```
Or pass `renderStep={null}` and call `useTourStep()` from any component — a fully headless tour.
`step.where` tells you exactly where the target is (`visible`, `offscreen`, `otherScreen`, `notFound`).
## Waiting for your app
A step can wait for something to happen in your app:
```ts
{ target: "exportButton", text: "Export this report.", advanceOn: { event: "report.exported" } }
```
```tsx
const { emit } = useTour();
async function exportReport() {
await api.export();
emit("report.exported");
}
```
Declare the event names in `defineTargets(…, { events: ["report.exported"] })` to have typos caught.
---
# Routers
A router adapter lets tours follow users between screens: it says which screen is in front, and how
to go to another. Screens are named the way your router names them, and your targets use the same
names.
| Router | Import | Screen names |
| --- | --- | --- |
| Next.js App Router | `useNextRouter()` from `hintbeam/next` | pathnames, `"/settings"` |
| React Router 6/7, Remix | `useReactRouter()` from `hintbeam/react-router` | pathnames |
| Expo Router | `useExpoRouter()` from `hintbeam/expo-router` | pathnames, `"/food"` |
| React Navigation 6/7 | `useReactNavigation(navigationRef)` from `hintbeam/react-navigation` | route names, `"Food"` |
| None | leave `router` out | everything is one screen |
Each adapter is optional: install only the router you use. The provider must sit where the router's
hooks work — inside ``, inside the Next.js layout, in Expo's root `_layout.tsx`. React
Navigation takes a ref instead, so its provider can sit outside `NavigationContainer`.
## Pointing at the way to a screen
When a step's target is on another screen, the tour highlights the link to that screen and offers
**Take me there**. Mark links with `useScreenLink`:
```tsx
const reportsLink = useScreenLink("/reports");
Reports
```
Without one, "Take me there" still works — there's just nothing to highlight.
## Any other router
`useRouterAdapter(currentScreen, navigate)` turns anything into an adapter:
```tsx
import { useRouterAdapter } from "hintbeam";
const router = useRouterAdapter(window.location.hash.slice(1) || "/", (to) => {
window.location.hash = to;
});
```
TanStack Router, Wouter, a state machine, a tab component — if it has "where am I" and "go there",
it works. For tests and storybooks, `createManualRouter("/start")` gives you one you drive by hand.
## Dynamic routes
Screens match exactly. For `/projects/42`, declare the target's screen as the path the user will be on
when the tour runs, or build the tour at runtime:
```ts
const tour = defineTour(targets, { id: `project-${id}`, steps: [...] });
```
Pattern matching (`/projects/:id`) is planned.
---
# Plugins
Plugins add to a tour without forking it. A plugin is a plain object:
```ts
import type { TourPlugin } from "hintbeam";
const analytics: TourPlugin = {
name: "analytics",
onEvent(event) {
track(`tour_${event.type}`, { tour: event.tour.id, step: "index" in event ? event.index : undefined });
},
};
```
| Hook | Use it for |
| --- | --- |
| `name` | Required, unique. Shown in development warnings |
| `paths` | Add path styles: `{ zigzag: myStyle }`, then `path="zigzag"` |
| `tours(context)` | Supply tours as data — a CMS, a file, a hosted editor. Each is validated first |
| `onEvent(event, context)` | Every `start`, `step`, `complete`, `skip`, `stop` — analytics, logging |
| `canStart({ tour, completed, user, platform })` | Decide whether a tour may start — audiences, frequency caps, experiments, entitlements. Return `false` to block; `start()` then resolves to `"blocked"` |
| `setup(api)` | Drive tours: start, stop, add tours at runtime, list the targets on screen. Return a cleanup |
| `apiVersion` | The plugin API version you wrote against (currently `1`) |
Rules that keep plugins safe:
- **A failing plugin never breaks a tour.** Errors in `onEvent` are reported and ignored; a
`canStart` that throws is treated as "allow".
- **Plugins add, they don't replace.** They cannot change how targets are found, how steps end or
what the card does.
- **Every plugin must agree** for a tour to start.
## Examples
Show a tour only once:
```ts
const onlyOnce: TourPlugin = { name: "only-once", canStart: ({ completed }) => !completed };
```
Only for new users:
```ts
const newUsers: TourPlugin = { name: "new-users", canStart: () => user.createdAt > Date.now() - 7 * 864e5 };
```
## Tours from anywhere
A plugin can supply tours as plain data — from a CMS, a JSON file, a hosted editor. Every tour is
validated against your declared targets before it can play; invalid ones are reported and left out.
```ts
const cms: TourPlugin = {
name: "cms",
async tours({ user }) {
const response = await fetch(`/api/tours?locale=${user?.traits?.locale ?? "en"}`);
return response.json();
},
};
const { start } = useTour();
start("first-meal"); // by id, from any plugin or
const tours = useTours(); // everything that can be started, e.g. for a help menu
```
## Knowing who the user is
```tsx
```
`user` is handed to `canStart` and `onEvent`. hintbeam itself never sends it anywhere.
## Driving tours from a plugin
`setup(api)` runs when the provider mounts and may return a cleanup. `api` can `start`, `stop`,
`getState`, `currentScreen`, `addTours`, and list `drawnTargets()` — the named targets on screen
right now, with their boxes. That is everything a live preview or a visual editor needs:
```ts
const livePreview: TourPlugin = {
name: "live-preview",
setup(api) {
const socket = new WebSocket("wss://editor.example.com/preview");
socket.onmessage = (message) => {
const { added } = api.addTours([JSON.parse(message.data)]);
if (added[0]) void api.start(added[0]);
};
return () => socket.close();
},
};
```
## Metadata
Tours and steps accept `meta` — plain JSON up to 4 KB that travels with the tour and is never shown
to users. Use it for versions, experiments, audiences or editor notes.
## Versioning
`TOUR_PLUGIN_API_VERSION` is the version of this contract (currently 1). Set `apiVersion` on your plugin;
the provider warns when a plugin needs a newer hintbeam. The contract only changes in
backwards-compatible ways within a major version.
## Open source, and what may come later
Everything in this repository — every platform, router, path style and this plugin API — is MIT and
stays MIT. The library never checks a licence key and never phones home.
Add-ons that need a server — hosted tour editing, live preview, analytics dashboards, audience
targeting, cross-device progress — may be offered separately. They plug in through exactly the API
on this page, so nothing in the open-source library is held back, and a free user never runs code
they did not choose to install.
---
# Accessibility
What the built-in card does:
- **Announces each step once** to screen readers — on the web through the card's polite live region,
on React Native through `AccessibilityInfo.announceForAccessibility`. Change the wording with
`labels.announce`.
- **Never steals focus.** The tour is a companion, not a modal; the user stays where they were.
- **Real buttons.** Next, Back, Skip, Show me and Take me there are buttons with visible focus rings
on the web and button roles on native. On native they are at least 44 × 44 points; on the web they
are 34px tall with a 44px minimum width, above the WCAG 2.2 AA minimum target size of 24 × 24px.
- **Keyboard (web).** Esc skips, → next, ← back — ignored while typing in a field.
- **Reduced motion.** With the system setting on, the light is drawn still: no travelling spark, no
draw-on animation.
- **Never points at nothing.** If a target cannot be found the card says so in words.
What we do not claim: no WCAG conformance claim is made for the library. Conformance belongs to your
app as a whole and needs testing with real assistive technology. Please report anything that gets in
a user's way — accessibility issues are treated as bugs.
---
# API reference
Everything is imported from `"hintbeam"` unless noted. It picks the web or React Native build for
you.
## Naming
Hintbeam lives next to your UI kit, your router and your own code, so its names never clash with
theirs:
- **Every export is a hook (`use…`) or says "tour":** `TourProvider`, `createTourTheme`,
`TourStep`, `TOUR_STYLES`. You can import `createTheme` from MUI or Mantine in the same file.
- **The few exceptions are already specific:** `defineTargets`, `TargetName`, `GuideOrb`,
`createManualRouter`.
- **Groups of built-ins share one shape:** `TOUR_STYLES`, `TOUR_THEMES`, `TOUR_PATHS`,
`TOUR_LABELS`, `TOUR_LIMITS`.
- **`"hintbeam"` is the app API.** The engine underneath it (player, registry, geometry, path
maths) is in [`hintbeam/core`](#hintbeamcore), for building renderers, light styles and plugins.
A check in CI keeps it this way: a new export with a generic name fails the build.
## Defining
### `defineTargets(definitions, options?)`
```ts
const targets = defineTargets(
{ search: { screen: "/food", about: "The search box" }, helpIcon: {} },
{ events: ["meal.logged"] },
);
```
- `definitions[name].screen?: string | null` — the screen it lives on; omit for every screen.
- `definitions[name].about?: string` — a note for tour authors.
- `options.events?: string[]` — event names steps may wait for (catches typos).
Returns `TourTargets` with `names`, `events`, `has(name)`, `screenOf(name)`, `about(name)`.
### `defineTour(targets, tour)`
Typed to your targets; validates immediately and throws a readable error if anything is wrong.
### `parseTour(json, targets)` → `{ ok: true, tour } | { ok: false, problems }`
For tours you load. Never throws. `problems` are `{ path, message }`, e.g.
`{ path: "steps[1].target", message: '"serach" is not a declared target. Did you mean "search"?' }`.
### `validateTour(input, targets)` → `TourProblem[]` · `formatTourProblems(id, problems)` → `string`
### Types
```ts
interface Tour { id: string; steps: TourStep[]; meta?: TourMeta }
interface TourStep { target: string; title?: string; text: string; advanceOn?: AdvanceOn; meta?: Record }
type AdvanceOn = "next" | "tap" | "arrive" | { event: string; timeout?: number };
```
Limits (`TOUR_LIMITS`): 1–8 steps; title 2–60 characters; text 4–160; timeout 5–300 seconds, default 30.
## ``
| Prop | Type | |
| --- | --- | --- |
| `targets` | `TourTargets` | **Required.** From `defineTargets` |
| `router` | `TourRouter` | From a router adapter. Default: one screen |
| `storage` | `TourStorage` | Where progress is kept: `browserTourStorage()` (web), `createTourStorage(AsyncStorage)` (native). Default: in memory |
| `labels` | `Partial` | Every word users read |
| `theme` | `TourThemeInput` | From `createTourTheme(…)`, or any `TourTheme` values. Default: `TOUR_THEMES.light` |
| `path` | `"wave" \| "strands" \| "straight" \| "elbow" \| "arc" \| string` | Path style. Default `"wave"` |
| `tours` | `Tour[]` | Tours startable by id: `start("first-meal")` |
| `user` | `{ id?, traits? }` | Who is using the app — handed to plugins, never sent anywhere |
| `plugins` | `TourPlugin[]` | Add-ons |
| `onEvent` | `(event: TourEvent) => void` | Every start, step, complete, skip, stop |
| `renderStep` | `(step: TourStepView) => ReactNode \| null` | Your own card; `null` for headless |
## Hooks
### `useTarget(name | names, { radius? })` → `ref`
Attach to the element. `radius` makes the highlight ring follow rounded corners. Typed to your targets
once you [register them](#typing-useTarget).
### `useScreenLink(screen, { radius? })` → `ref`
Attach to the link, tab or menu item that leads to `screen`.
### `useTourScroll(screen)`
- **Web:** returns a `ref` for an element that scrolls its own content. Not needed for the page itself.
- **React Native:** returns props to spread onto the screen's `ScrollView`: ``.
### `useTour()`
| | |
| --- | --- |
| `start(tour \| id)` | Play from the first step. Resolves to a `TourStartResult`: `"started"`, `"already-playing"`, `"blocked"` (by a plugin) or `"unknown-tour"` |
| `resume(tour)` | Play from where the user skipped, or from the start. Also `"resumed"` |
| `startOnce(tour)` | Play only if this user has never finished or skipped it; waits for saved progress first. Also `"seen"` |
| `next()`, `back()` | |
| `skip()` | Close and remember the step for `resume` |
| `stop()` | Close without remembering |
| `emit(event)` | Ends a step waiting for that event |
| `isActive`, `tour`, `step`, `index`, `total`, `isFirst`, `isLast`, `waitedTooLong` | Current state |
| `hasCompleted(tour)`, `canResume(tour)` | |
### `useTours()` → `Tour[]`
Every tour that can be started by id: those passed to `TourProvider tours` and those provided by
plugins. Useful for a help menu.
### `useTourStep()` → `TourStepView`
Everything the built-in card uses: the state above plus `where` (`visible` / `offscreen` /
`otherScreen` / `notFound`), `highlight` (`{ box, radius }`), `primary` (`"next"`, `"done"`,
`"showMe"`, `"takeMeThere"`, `"wait"`), `labels`, and `next`, `back`, `skip`, `showMe`,
`takeMeThere`, `refresh`, and for custom UIs that follow the page: `track()` (measure the highlighted
element this instant, without finding it again) and `key` (changes with each step, for keying animations).
## Routers
`useNextRouter()` (`hintbeam/next`) · `useReactRouter()` (`hintbeam/react-router`) ·
`useExpoRouter()` (`hintbeam/expo-router`) · `useReactNavigation(ref)` (`hintbeam/react-navigation`) ·
`useRouterAdapter(screen, navigate)` for your own router · `createManualRouter(initial)` for tests and
apps that track the screen themselves.
```ts
interface TourRouter {
currentScreen(): string | null;
navigate(screen: string): void | Promise;
subscribe(listener: (screen: string | null) => void): () => void;
}
```
## Storage
```ts
browserTourStorage(key?) // web: localStorage, safe in server rendering (Next.js)
createTourStorage(store, key?) // any getItem/setItem store: AsyncStorage, MMKV, your API; never throws
mergeTourProgress(a, b) // join two devices' copies safely
interface TourStorage { load(): Promise; save(state: TourProgressState): Promise }
```
Without `storage`, progress is kept in memory until the page or app reloads.
## Light styles
Pick one by name: `path="wave"` on `TourProvider`, or `path` in a theme. `TOUR_PATHS` holds the
built-ins: `wave` (default), `strands`, `straight`, `elbow`, `arc`. Their names are `TourPathName`.
Add your own with `defineTourPath` and give it to a plugin's `paths`. The geometry helpers come from
`hintbeam/core`:
```ts
import { defineTourPath } from "hintbeam";
import { center, routeThrough } from "hintbeam/core";
// A straight line to the middle of the target.
const direct = defineTourPath((from, to, options) => {
const end = center(to);
return routeThrough(from, [[from, end, end]], options?.samples);
});
// type TourPath = (from: Point, to: Box, options?: TourPathOptions) => PathRoute
```
## Components
`GuideOrb` — the guide on its own: `{ mood?: "point" | "wait" | "search" | "done"; angle?; size?; accent?; accent2? }`.
On React Native it takes `{ accent, accent2, angle, waiting, searching, reduceMotion, size? }`.
## Plugins
```ts
interface TourPlugin {
name: string;
apiVersion?: number; // TOUR_PLUGIN_API_VERSION, currently 1
paths?: Record;
tours?(context: TourPluginContext): unknown[] | Promise; // validated before use
canStart?(check: { tour; completed; user; platform }): boolean | Promise;
onEvent?(event: TourEvent, context: TourPluginContext): void;
setup?(api: TourPluginApi): void | (() => void); // start, stop, getState, addTours, drawnTargets…
}
interface TourPluginContext { user: { id?: string; traits?: Record } | null; platform: string }
```
## Keyboard (web)
While a step shows: **Esc** skips, **→** next, **←** back. Ignored while typing in a field.
## Typing `useTarget`
Register your targets once for autocomplete everywhere:
```ts
declare module "hintbeam" {
interface Register {
targets: typeof targets;
}
}
```
## `hintbeam/core`
The engine without React. It has everything above that isn't a component, hook, theme or label,
and adds the pieces underneath:
- **Engine:** `TourPlayer`, `ElementRegistry`, `resolveTarget`, `memoryTourStorage` (the default), `noRouter`.
- **Geometry:** `Box`, `Point`, `PathRoute`, `center`, `contains`, `inflate`, `edgeNormal`,
`meetPoint`, `nearEdge`, `visibilityOf`, `scrollToShow`.
- **Path maths:** `routeThrough(start, segments, samples?)`.
- **Card placement:** `placeStep(viewport, target, cardHeight, options)` returns
`{ left, top, width, side, edge, guide }`. `edgeBeacon(viewport, target, "up" | "down", band?)`
returns where the light ends for a target scrolled away.
Use it to validate tours on a server or in CI, or to build a renderer, a light style or a new
platform (see [Architecture](https://hintbeam.js.org/docs/architecture)). It has no React import, so it is safe anywhere.
---
# Architecture
hintbeam is built in layers. Each layer depends only on the ones below it, so a new platform,
router, path style or add-on is an addition — never a fork.
```
┌────────────────────────────────────────────────────────────────────────────┐
│ plugins analytics · targeting · path styles · add-ons │
├────────────────────────────────────────────────────────────────────────────┤
│ routers next · react-router · expo-router · react-navigation · own │
├──────────────────────────────┬─────────────────────────────────────────────┤
│ web platform │ native platform │
│ React DOM, Next.js, Vite, │ React Native, Expo │
│ Expo web │ │
├──────────────────────────────┴─────────────────────────────────────────────┤
│ react TourProvider factory · useTarget · useTour · useTourStep │
├────────────────────────────────────────────────────────────────────────────┤
│ core tours & validation · targets · finding · player · │
│ progress · path styles · card layout · plugin host │
│ (pure TypeScript, no React, no platform) │
└────────────────────────────────────────────────────────────────────────────┘
```
## core
Pure TypeScript with injected time, storage and measurement. Every rule — validation, the four-way
"where is the target" decision, how steps end, skip and resume, the progress merge, path geometry,
card placement — is unit-tested here without a screen.
- `ElementRegistry` holds refs, never positions. Measuring happens only when a step asks.
- `resolveTarget` turns a target name into `visible` / `offscreen` / `otherScreen` / `notFound`.
- `TourPlayer` is the state machine: start, next, back, skip, stop, tap, arrive, event, timeout.
- Path styles are pure functions `(from, to) => Route`; every renderer draws any route.
## react
`createTourProvider(platform)` builds a `TourProvider` for a platform. The hooks are shared: they
only ever talk to the core and to the platform interface.
## platforms
A platform implements `TourPlatform`:
```ts
interface TourPlatform {
name: string;
measurable(ref): Measurable; // where is this element, in viewport coordinates
defaultScrollArea?(): ScrollArea | null; // the page, on the web
TapObserver: Component<{ onTap(point) }>; // see taps without blocking them
StepUI: Component<{ view, theme, path }>; // the built-in card and light
useLayoutChanges(invalidate): void; // resize, rotation
useKeyboard?(actions): void; // shortcuts
}
```
`hintbeam` resolves to the right platform automatically through the package's `exports`
conditions (`react-native` → native, everything else → web). To add a platform — Vue, Svelte, a
canvas — implement this interface (or reuse `hintbeam/core` directly with your framework's own
reactivity).
## routers
Each adapter is a few lines over `useRouterAdapter(screen, navigate)`. Router packages are optional
peer dependencies: an app installs only the one it uses.
## plugins
`composePlugins` merges plugins into one host: path styles are added to the registry; events fan out
to every plugin; `canStart` requires every plugin to agree. A failing plugin is reported and ignored —
the open-source behaviour always wins.
## Design rules
1. **Measure, never remember.** Positions are read when needed and re-read when anything moves.
2. **Never point at nothing.** Every outcome of finding a target has an honest UI.
3. **A companion, not a modal.** The app stays usable; tapping the real button is a valid step.
4. **Data first.** Tours are validated data, the same in code and in JSON.
5. **Add, don't fork.** Platforms, routers, paths and plugins plug in; none edits the core.
---
# Versioning and releases
hintbeam follows [Semantic Versioning](https://semver.org). This page says exactly what that
promises, so you can upgrade without reading the source.
## What counts as the public API
The public API is everything you can import from these entry points, with the shapes they have:
`hintbeam` · `hintbeam/web` · `hintbeam/native` · `hintbeam/core` ·
`hintbeam/next` · `hintbeam/react-router` · `hintbeam/expo-router` ·
`hintbeam/react-navigation`
It also covers:
- the tour format that `parseTour` accepts (JSON tours stored in a database keep working);
- the progress format written to storage (saved progress survives upgrades);
- the plugin API, versioned separately as `TOUR_PLUGIN_API_VERSION`.
Theme the card through `theme` (or `createTourTheme`). The `--hb-*` CSS custom properties the card
sets are not public yet; they become public once listed in [Customising](https://hintbeam.js.org/docs/customising).
Not public: anything reached through a deep path such as `hintbeam/dist/...`, class names
beginning with `hb-`, and the exact pixels of the built-in card. The card's look improves in minor
releases. If you need it frozen, use `renderStep` or the headless `useTourStep`.
Every export and its shape is recorded in [`api/surface.txt`](https://github.com/CypherRatHQ/hintbeam/blob/main/api/surface.txt). CI fails if it
changes without the file being updated in the same pull request, so the API can never change by
accident.
## What each kind of release may do
| Release | May | May not |
| --- | --- | --- |
| **Patch** `1.2.x` | Fix bugs, improve docs, make things faster | Change behaviour you could reasonably rely on |
| **Minor** `1.x.0` | Add exports, options, path styles and labels; deprecate things; refine the default look | Remove or rename anything; change a type so existing code stops compiling |
| **Major** `x.0.0` | Remove what an earlier minor deprecated; raise the minimum React, React Native or Node version | Surprise you: every change is in the changelog with its upgrade step |
### Before 1.0
While the version starts with `0.`, the **minor number acts as the major**:
- `0.3.0` may contain breaking changes, but only ones deprecated in `0.2.x`, with a warning first.
- `0.2.4` contains only fixes and additions.
We aim for 1.0 once the API has held still through real use, including React Native on devices.
## Deprecation, step by step
1. **Announce:** in a minor release, the old way keeps working. It is marked `@deprecated` in the
types, so editors strike it through, and it logs one warning in development, naming the
replacement.
2. **Wait:** it stays for at least one full minor release, and in practice for the rest of that
major.
3. **Remove:** only in the next major. The changelog entry includes the upgrade step, and a codemod
when the change is mechanical.
## Supported versions
| Dependency | Supported |
| --- | --- |
| React | ≥ 18 |
| React Native | ≥ 0.73 (with `react-native-svg` ≥ 13) |
| Next.js | ≥ 13.4 (App Router) |
| React Router | ≥ 6 |
| Expo Router | ≥ 3 |
| React Navigation | ≥ 6 |
| Node (for `hintbeam/core` on servers and in CI) | maintained LTS versions |
Dropping a version from this table is a major change.
Security fixes go to the latest minor of the current major. They also go to the last minor of the
previous major for six months after a new major ships.
## Plugins
Plugins declare `apiVersion`. The plugin API is versioned on its own:
- **Additions** keep `TOUR_PLUGIN_API_VERSION` the same.
- **A breaking change to the plugin API** raises it, and happens only in a major release of the
library.
A plugin built for an older version keeps working, or is skipped with a clear warning — it never
fails silently.
## Release channels
- **`latest`:** the default, from `npm i hintbeam`.
- **`next`:** previews of the coming release, from `npm i hintbeam@next`, versioned
`0.4.0-next.1` and so on. Previews may change between builds. Use them to try what's coming, not
in production.
## How a change becomes a release
This is for contributors; see also [CONTRIBUTING](https://github.com/CypherRatHQ/hintbeam/blob/main/CONTRIBUTING.md).
1. **Branch from `main`.** `main` is always releasable. Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, `docs:`, …).
2. **Add a changeset** for anything users can notice: `npx changeset`. Choose patch, minor or
major, and write the summary for the person upgrading.
3. **Run `npm run check`.** It runs the types, tests, the API-surface check and formatting. If the
API changed on purpose, run `npm run api:update` and commit `api/surface.txt` too.
4. **Merge the pull request.** CI then opens a "Version packages" pull request that bumps the
version and writes the changelog from the changesets.
5. **Merge that pull request to publish** to npm, with a git tag `vX.Y.Z` and release notes. A
maintainer approves this step; nothing publishes on its own.
---
# hintbeam
## 0.1.0
### Minor Changes
- 0a99795: First public release.
**Naming.** `import … from "hintbeam"` is the app API: about 70 names, each one a hook or naming the
tour domain (`TourProvider`, `createTourTheme`, `TourStep`, `TOUR_STYLES`), so nothing clashes with a
UI kit, a router or your code. The engine (player, registry, geometry, path maths) is in
`hintbeam/core`. CI fails on a new generic name in the main entry.
**Added**
- **Core** (`hintbeam/core`, zero dependencies):
- Tours and targets: `defineTargets`, `defineTour`, `parseTour`, `validateTour` and
`formatTourProblems`, with "did you mean…?" hints.
- Finding targets: `ElementRegistry` (the first drawn element wins) and `resolveTarget`, which
returns `visible`, `offscreen`, `otherScreen` or `notFound`.
- Playing tours: `TourPlayer`, with steps that end on `next`, `tap`, `arrive` or an event with a
timeout. Skip pauses the tour, `resume` continues it, and a double start counts as one.
- Progress: `mergeTourProgress` (property-tested to be commutative, associative and idempotent), plus
`memoryTourStorage` and `createTourStorage`.
- Light styles in `TOUR_PATHS`: `wave`, `strands` (three lines of light), `straight`, `elbow` and
`arc`, with `defineTourPath` and `routeThrough` for your own. Routes may carry companion `strands`. Curved
styles leave along `leave` (straight out of the card edge) and meet the target's edge square on;
`edgeNormal`, `inflate` and `meetPoint` (in `hintbeam/core`) help custom styles. Each end's reach grows with
the distance travelled, so lights are smooth S-curves at any angle, and they meet wide targets at
the nearest point of the facing edge's middle half.
- Card placement: `placeStep` puts the card beside its target (below, above, right, left), docks
it on phones and toward a scrolled-away target, and returns where the guide sits. `edgeBeacon`
marks the screen edge a scrolled-away target is past.
- **Plugin API v1:**
- `TourPlugin` with `paths`, `tours`, `canStart`, `onEvent` and `setup`.
- `TourPluginApi` with `start`, `stop`, `getState`, `addTours` and `drawnTargets`.
- The `user` context, and `meta` on tours and steps.
- **React layer:**
- Components and hooks: `TourProvider`, `useTarget`, `useScreenLink`, `useTour`, `useTours` and
`useTourStep`.
- Looks from plain to magical: `createTourTheme({ brand, mode, style })` builds a full theme from a
brand colour, light or dark cards, and one of four `TOUR_STYLES` — `aurora`, `balanced`
(default), `subtle`, `minimal`. Every part can still be overridden.
- Themes (`TOUR_THEMES.light`, `TOUR_THEMES.dark`) with `accent2`, `border`, `guide`, `backdrop`, `glow` (one
dial for every glow), `motion` (`full`, `calm`, `none`) and `path`, and
translatable `labels` (including `yourTurn`).
- `useTourStep` gives `track()` and `key`, for custom UIs that follow the page.
- `useTour().startOnce(tour)` for first-visit tours: it plays only if the user has never finished
or skipped the tour, and waits for saved progress before deciding, so it is right with
asynchronous storage such as AsyncStorage. It resolves to `"seen"` when it does nothing.
- Targets that render late are found the moment they mount, even while their step is showing.
- **The built-in step UI** (web and native):
- The guide: a living light on the card's edge that leans toward the target, breathes faster
while a step waits, and searches when the target is elsewhere. Also exported as `GuideOrb`.
- The light leaves the guide, blends `accent` → `accent2`, draws once per step and then follows
the target every frame while anything scrolls (web). A scrolled-away target gets a glow and a
chevron on the screen edge it is past.
- Spotlight backdrop with a cut-out around the target (never blocks anything), a traced and
pulsing highlight, a progress bar, word-by-word text and a "Your turn" state. Skip sits apart from Back and the main button.
- Custom or headless step UIs through `renderStep`.
- **Web platform:**
- `browserTourStorage()` keeps progress in `localStorage`, and is safe in server rendering.
- Measures elements with the DOM; the page itself is the default scroll area.
- The step card renders in a portal, with an SVG light animated in CSS.
- Keyboard: Esc skips, → goes to the next step, ← goes back.
- Respects reduced motion, and is marked `"use client"` for the Next.js App Router.
- **Native platform:**
- Measures with `measureInWindow` and draws the light with react-native-svg.
- Announces steps to screen readers and respects reduced motion.
- `useTourScroll` for ScrollViews.
- **Router adapters:** Next.js, React Router, Expo Router, React Navigation, and
`useRouterAdapter` for any other router.
- **Documentation and website:** guides in `docs/`, and the website in `site/` (Next.js, static), which uses the package itself.
Every release, newest first. Entries are written for the person upgrading: what changed, why it
matters, and the upgrade step when there is one. Versions follow [Semantic Versioning](https://semver.org)
as described in [docs/versioning.md](docs/versioning.md).
Changes waiting for the next release live in [`.changeset/`](.changeset); they are added here when
the release is cut.
## Unreleased