# @inklu/tour Accessible, headless product tours for React. Spotlight overlay, resilient target tracking, and full composition control. Docs: [inklu.saddy.me/tour](https://inklu.saddy.me/tour) · For agents: [llms.txt](https://inklu.saddy.me/tour/llms.txt) · Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-tour` - **Accessible by default** — real `dialog`, focus moves in/out, screen reader live regions. - **Resilient targeting** — waits for targets that mount late (via `MutationObserver`, no polling), follows them through scroll, resize and layout shifts, and centers the card when a step has no target. - **Headless or batteries-included** — drop in ``, or compose `TourRoot` / `TourSpotlight` / `TourCard` yourself. - **Small footprint** — two runtime dependencies (`@floating-ui/react-dom` for positioning, `zustand` for state), CSS-only animations, and `"use client"` entry points for React Server Components apps. ## Install ```sh npm install @inklu/tour ``` `react` and `react-dom` (18 or 19) are peer dependencies. ## Quick start ```tsx import { TourProvider, useTour, type TourConfig } from "@inklu/tour"; const tours: TourConfig[] = [ { id: "onboarding", steps: [ { id: "sidebar", target: "#sidebar", // Target any CSS selector placement: "right", meta: { title: "Your workspace", content: "Everything lives here." }, }, { id: "compose", placement: "bottom-start", // Automatically finds elements with data-tour-step="compose" meta: { title: "Compose", content: "Start writing." }, }, ], }, ]; export function App({ children }: { children: React.ReactNode }) { return {children}; } // In any component inside App: function StartButton() { const { startTour } = useTour(); return ; } ``` ## Steps and targets | Field | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------ | | `id` | Unique step id. Without a `target`, the step anchors to `[data-tour-step=""]`. | | `target` | A CSS selector, or `{ selector, timeout?, strategy? }`. Several matches are spotlit together. | | `placement` | `top`, `bottom`, `left`, `right`, optionally with `-start`, `-center` or `-end`. | | `route` | Passed to `TourProvider`'s `onNavigate` once when the step becomes active. | | `meta` | Anything you render: `title` and `content` are used by the built-in card. Typed via `TourStep`. | | `beforeStep`, `afterStep` | Async hooks that prepare and clean up a step (open a menu, close it). See [Step hooks](#step-hooks). | When a target isn't in the DOM yet, the tour waits for it. After `timeout` (default 3000 ms) the `strategy` decides: `"wait"` (default) shows the card centered, `"skip"` moves on in the direction the tour was going (next, or previous when the user was going back), `"error"` closes the tour and calls `onError`. A step with no target and no matching `data-tour-step` element is shown centered straight away. ### Step hooks `beforeStep` runs before a step becomes active, including the step a tour opens on. `afterStep` runs when the tour leaves a step it entered, whether by moving on, finishing, being dismissed or being closed from outside. So a menu opened in `beforeStep` and closed in `afterStep` is never left open. - Moving between steps, and finishing with "Next", waits for both hooks. The card shows a waiting state meanwhile, and a target's `timeout` only starts once they settle. - A dismissal closes at once and lets `afterStep` finish afterwards. - A hook that throws is reported to `onError`. Between steps, the tour stays on the current step. A throwing `beforeStep` on the opening step still shows that step. ## Configuration Pass `config` to `TourProvider` / `TourRoot`, set it per tour on `TourConfig.config`, or change it at runtime with `updateConfig` (later sources win). | Option | Default | Description | | --------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `keyboardNavigation` | `true` | Arrow keys move between steps, following the reading direction (in RTL, ArrowLeft is next). They never finish the tour; that takes "Finish". Ignored while focus is in a text field or a widget that uses arrow keys (slider, tabs, listbox, menu…). | | `dismissOnEscape` | `true` | Escape closes the tour. | | `closeOnOutsideClick` / `closeOnOverlayClick` | `false` | Close on a press outside the card / on the dimmed overlay. | | `blockInteraction` | `true` | Stops presses from reaching the page behind the overlay. The card and the spotlit targets stay interactive. Needs `showSpotlight`. | | `autoScroll` | `true` | Scrolls an off-screen target to the middle of the viewport; instant under `prefers-reduced-motion`. | | `showSpotlight`, `spotlightPadding`, `spotlightRadius`, `maskOpacity` | `true`, `8`, target radius + 4, `0.6` | Overlay and cutout. | | `targetPulse` | `false` | Pulses the cutout outline (disabled under `prefers-reduced-motion`). | | `cardOffset`, `showArrow` | `16`, `true` | Card distance from the target, and its arrow. | | `trapFocus`, `autoFocus`, `restoreFocus` | `false`, `true`, `true` | Focus management. The card is focused once it is visible; on close, focus returns to where it was unless the user has since moved it to the page. | | `announceSteps` | `true` | Announces each step in a polite live region. | | `theme`, `dir` | `"light"`, `"ltr"` | The default card also turns dark under a `.dark` ancestor, so it follows an app's own theme toggle. `"system"` follows the OS instead; `"auto"` follows ``, including later changes. | | `labels`, `classNames`, `unstyled`, `zIndex` | | Button text and step counter, per-slot classes, opting out of the built-in styles, stacking. | ## Headless API `TourProvider` renders a ready-made card. For your own markup, compose the parts yourself. Only `TourProvider` injects the stylesheet; the parts are unstyled apart from positioning. ```tsx import { TourRoot, TourSpotlight, TourCard, TourArrow, TourNextButton, TourPreviousButton, TourCloseButton, useTourContext, } from "@inklu/tour"; function MyTour({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { const [step, setStep] = React.useState(0); return ( ); } function StepBody() { const { currentStep, currentStepIndex, totalSteps, labelId, descriptionId } = useTourContext(); return ( <>

{currentStep?.meta?.title}

{currentStep?.meta?.content}

{currentStepIndex + 1} / {totalSteps} Back Next Close ); } ``` - **`TourRoot`** — state and behaviour, no DOM of its own. `open` / `stepIndex` are controlled (omit them to drive the tour through `store`). With only `open` controlled, each open starts at the first step. Callbacks: `onOpenChange`, `onStepChange`, `onComplete`, `onDismiss`, `onTargetWaiting`, `onTargetFound`, `onTargetTimeout`, `onError`. They fire whatever moved the tour, including the imperative controls on a shared `store`; changes you make through `open` / `stepIndex` aren't reported back. Also takes `config`, `container` (portal target) and `store`. - **`TourSpotlight`** — the SVG overlay with a cutout per target. Props: `padding`, `maskOpacity`, `fill`, `stroke`, `strokeWidth`, `strokeOpacity`. - **`TourCard`** — the positioned `role="dialog"`. It points `aria-labelledby` / `aria-describedby` at `labelId` / `descriptionId` only when you render elements with those ids; with no title element, a string `meta.title` becomes its `aria-label`. Exposes `data-side`, `data-open`, `data-state`, `data-moving` and `data-centered` for styling. `TourArrow` paints itself with `--tour-arrow-bg` / `--tour-arrow-border` (falling back to `--tour-bg` / `--tour-border`); set them to your card's colours. - **`TourArrow`**, **`TourNextButton`**, **`TourPreviousButton`**, **`TourCloseButton`** — all accept `asChild` to render your own element. - **`useTourContext()`** — current step, index, targets and their viewport `rects` (the same measurements the card and spotlight use), config, ids for `aria-labelledby` / `aria-describedby`, and `next` / `previous` / `close` / `setStep`. ### Stores and imperative control Every tour runs on a zustand store. `TourRoot` creates a private one per instance unless you pass `store`; `TourProvider` uses the shared default `tourStore`, which the imperative `tour` object drives: ```ts import { tour } from "@inklu/tour"; tour.start("onboarding"); // works outside React, even before the provider mounts tour.next(); tour.goToStep(2); // runs step hooks; out-of-range indices are ignored tour.updateConfig({ maskOpacity: 0.4 }); tour.stop(); ``` `tour.listen(listener)` (or `store.getState().listen`) reports `step`, `complete`, `dismiss` and `error` events, whatever moved the tour: its buttons, the keyboard, `useTour()` or `tour` itself. `TourRoot`'s callbacks and `TourProvider`'s `onStepChange(stepIndex, tourId)`, `onComplete(tourId)` and `onDismiss(tourId, stepIndex)` are built on it. Inside a provider, `useTour()` returns `startTour`, `stopTour`, `goToStep`, `updateConfig`, `activeTourId`, `isActive`, `stepIndex`, the effective `config` and the `store` — all reading from and writing to that same store. To run two independent providers, give each one its own store: `` and, if needed, `createTourControls(store)` for an imperative handle. ### Showing a tour once Record completed and dismissed tours, and only start the ones not seen yet: ```tsx const seen = (id: string) => localStorage.getItem(`tour:${id}`) !== null; const markSeen = (id: string) => localStorage.setItem(`tour:${id}`, "1"); {children} ; // After the app has mounted: if (!seen("onboarding")) tour.start("onboarding"); ``` ### Typed step meta ```ts interface Meta { title: string; video: string } const tours: TourConfig[] = [/* … */]; …; const { currentStep } = useTourContext(); // currentStep.meta is Meta | undefined ``` ## Using this with a coding agent An agent skill ships alongside the library to help an AI agent build your tours. ```sh npx skills add nkurunziza-saddy/inklu --skill inklu-tour ``` ## License MIT