# 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