Skip to content
On this page

@inklu/tour

Updated October 2026

@inklu/tour runs product tours in React. It finds each step's target, positions a card against it, cuts a spotlight in the backdrop, and handles focus, keyboard navigation and screen-reader announcements. Use its card, or render your own.

Installation

$
pnpm add @inklu/tour

Peers: react and react-dom 18 or 19. The package ships "use client", so it can be imported from server components.

Quick start

Define tours, wrap the app in TourProvider, and start one with useTour from any component inside it.

import { type TourConfig, TourProvider, useTour } from "@inklu/tour";
const tours: TourConfig[] = [
{
id: "onboarding-tour",
steps: [
{
id: "step-welcome",
target: "#welcome-header",
placement: "bottom",
meta: {
title: "Welcome aboard!",
content: "Let's take a quick tour around the dashboard.",
},
},
{
id: "step-features",
target: ".feature-grid",
placement: "top",
meta: {
title: "Powerful Features",
// content accepts any ReactNode, not just strings.
content: <>Explore our <strong>tools</strong> and utilities.</>,
},
},
],
},
];
export function App({ children }: { children: React.ReactNode }) {
return <TourProvider tours={tours}>{children}</TourProvider>;
}
export function StartTourButton() {
const { startTour } = useTour();
return (
<button onClick={() => startTour("onboarding-tour")}>
Start Tour
</button>
);
}

Tours across pages

Pass onNavigate so a step on another route can push to your router. In Next.js, the provider goes in a client component:

// app/providers.tsx
"use client";
import { TourProvider, type TourConfig } from "@inklu/tour";
import { useRouter } from "next/navigation";
const tours: TourConfig[] = [/* ... */];
export function Providers({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<TourProvider tours={tours} onNavigate={(route) => router.push(route)}>
{children}
</TourProvider>
);
}

AI setup

Paste this prompt into Claude Code, Cursor or any coding agent with write access to your project. It needs no other context.

Prompt
Set up an accessible product tour using the `@inklu/tour` package.

Do not invent APIs. If something here is ambiguous, read https://inklu.saddy.me/tour
before guessing.

## 1. Install

`npm install @inklu/tour`

Peers: `react` and `react-dom` 18 or 19.

## 2. Define your tours

Create a `TourConfig[]` array. Each config has an `id` and a `steps` array.

```tsx
import { type TourConfig, TourProvider, useTour } from "@inklu/tour";

const tours: TourConfig[] = [
  {
    id: "onboarding-tour",
    steps: [
      {
        id: "step-welcome",
        target: "#welcome-header",
        placement: "bottom",
        meta: {
          title: "Welcome aboard!",
          content: "Let's take a quick tour around the dashboard.",
        },
      },
    ],
  },
];
```

## 3. Add the provider

Wrap your app (or the subtree that contains tour targets) with `TourProvider`:

```tsx
// app/providers.tsx — must be a Client Component
"use client";

import { TourProvider, type TourConfig } from "@inklu/tour";
import { useRouter } from "next/navigation";

const tours: TourConfig[] = [/* ... */];

export function Providers({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  return (
    <TourProvider tours={tours} onNavigate={(route) => router.push(route)}>
      {children}
    </TourProvider>
  );
}
```

`onNavigate` is required for cross-page tours.

## 4. Trigger a tour

Call `startTour` from the `useTour` hook anywhere inside the provider:

```tsx
"use client";
import { useTour } from "@inklu/tour";

export function StartButton() {
  const { startTour } = useTour();
  return <button onClick={() => startTour("onboarding-tour")}>Start Tour</button>;
}
```

## 5. Target elements

Prefer `data-tour-step` attributes over CSS selectors — they survive refactors:

```tsx
<button data-tour-step="step-welcome">Publish</button>
```

For async-loaded elements, use a target object with a timeout and strategy:

```tsx
target: { selector: ".async-loaded-modal", timeout: 3000, strategy: "skip" }
```

## 6. Composition (optional)

Replace the default card UI with `TourRoot` + primitives from `@inklu/tour`:
`TourSpotlight`, `TourCard`, `TourArrow`, `TourNextButton`,
`TourPreviousButton`, `TourCloseButton`, `useTourContext`.

Always wire `labelId` and `descriptionId` from `useTourContext` to the
corresponding elements in your card to maintain accessibility.

## Constraints

- Never put `TourProvider` in a Server Component file.
- Do not use dot notation like `<Tour.Card>` — import sub-components explicitly.
- The `useTour` hook throws if called outside a `TourProvider`.

## Before you finish

Confirm every `target` selector or `data-tour-step` value resolves to a real
element, and that the tour `id` passed to `startTour` matches a config id.

For an agent that keeps working in the repo, install the agent skill instead. It carries the rules, not just the setup:

$
pnpm dlx skills add nkurunziza-saddy/inklu --skill inklu-tour

Agents reading the site start at /tour/llms.txt. The complete reference, including what this page leaves out, is /tour/llms-full.txt.

Targeting

Leave out target and put data-tour-step with the step id on the element. It survives class and markup changes that break selectors.

// Tag the element instead of coupling the tour to a CSS selector:
<button data-tour-step="step-3">Publish</button>

For elements that load late, give the target a timeout and a strategy. "skip" moves on if the element never appears; "error" reports it to onError.

const tour: TourConfig = {
id: "advanced-tour",
steps: [
{
id: "step-1",
// Simple CSS selector
target: "#main-heading",
},
{
id: "step-2",
// Target with timeout & skip strategy
target: {
selector: ".async-loaded-modal",
timeout: 3000,
strategy: "skip", // Advances to the next step if still missing
},
},
{
// No target: the step id is matched against [data-tour-step="step-3"].
// Survives refactors that change classnames or markup structure.
id: "step-3",
},
],
};

Provider

<TourProvider
tours={tours}
onNavigate={(route) => router.push(route)}
onError={(error) => reportToSentry(error)}
config={{
closeOnOutsideClick: true,
closeOnOverlayClick: true,
keyboardNavigation: true,
dismissOnEscape: true,
showSpotlight: true,
spotlightPadding: 10,
maskOpacity: 0.7,
targetPulse: true,
cardOffset: 16,
zIndex: 9998,
}}
>
{children}
</TourProvider>

useTour controls the tour from anywhere inside the provider, and config can change while a tour runs:

const {
startTour, // (id: string) => void
stopTour, // () => void
goToStep, // (index: number) => void
stepIndex, // number
isActive, // boolean
activeTourId,// string | null
config, // TourConfigOptions
updateConfig,// (partial: Partial<TourConfigOptions>) => void
} = useTour();
// Configuration can be changed while a tour is running.
updateConfig({ maskOpacity: 0.3, targetPulse: true });

Custom card

Compose TourRoot, TourSpotlight and TourCard yourself to render any UI. Attach labelId and descriptionId so the dialog keeps its accessible name.

import {
TourRoot,
TourSpotlight,
TourCard,
TourArrow,
TourNextButton,
TourPreviousButton,
TourCloseButton,
useTourContext,
type TourRootProps,
} from "@inklu/tour";
export function CustomTour(props: TourRootProps) {
return (
<TourRoot {...props}>
<TourSpotlight fill="black" stroke="var(--tour-accent)" />
<TourCard className="custom-card-container">
<TourArrow />
<CustomBody />
</TourCard>
</TourRoot>
);
}
function CustomBody() {
// labelId / descriptionId keep the dialog's accessible name and description
// wired up. Always attach them when you replace the default card.
const { currentStep, currentStepIndex, totalSteps, labelId, descriptionId } =
useTourContext();
return (
<>
<h2 id={labelId}>{currentStep?.meta?.title}</h2>
<div id={descriptionId}>{currentStep?.meta?.content}</div>
<div className="flex justify-between">
<TourPreviousButton>Back</TourPreviousButton>
<TourNextButton>
{currentStepIndex === totalSteps - 1 ? "Done" : "Next"}
</TourNextButton>
</div>
<TourCloseButton>Dismiss</TourCloseButton>
</>
);
}

Step hooks, typed step meta, the tour store for control outside React, and showing a tour once are in the README, also served for agents as /tour/llms-full.txt.