# Accessibility (https://react-tourlight.vercel.app/docs/accessibility) Focus management, keyboard behavior, ARIA semantics, reduced motion, and what to verify in your integration. react-tourlight includes focus management, keyboard navigation, ARIA semantics, and reduced-motion behavior. These are building blocks for an accessible guide, not a certification of your application. Test your content, theme contrast, custom tooltips, and interactive targets with the input methods and assistive technologies your users rely on. ## Focus management When the tooltip is displayed, focus moves to its primary action. Non-interactive steps keep focus within the tooltip; interactive steps also need access to the highlighted target. On dismissal, focus should return to the control that launched the guide. Include this behavior in your browser checks, especially when routes or dialogs replace that control. ### Focus trap On non-interactive steps, **Tab** cycles through the tooltip's controls. On interactive steps, the highlighted target remains available to keyboard users. Verify that custom tooltips and application dialogs preserve a usable focus order. ## Keyboard navigation All tours and highlights support full keyboard navigation: | Key | Action | |-----|--------| | **Escape** | Dismiss the tour or highlight | | **Right Arrow** | Go to the next step | | **Left Arrow** | Go to the previous step | | **Tab** | Navigate available controls; non-interactive steps trap focus in the tooltip | | **Shift + Tab** | Reverse cycle through tooltip buttons | | **Enter / Space** | Activate the focused button | Escape can be disabled per-provider with `escToDismiss={false}`, though this is not recommended for accessibility. ## ARIA attributes The tooltip is rendered as a dialog with proper ARIA semantics: - **`role="dialog"`** -- Identifies the tooltip as a dialog to assistive technology - **`aria-labelledby`** -- Points to the step title element, giving the dialog an accessible name - **`aria-describedby`** -- Points to the step content element, giving the dialog an accessible description - **`aria-live="polite"`** -- Announces step changes to screen readers without interrupting the current announcement queue ### Example rendered markup ```html

Search

Find anything instantly with our search.

``` ## Screen reader support Step changes use an `aria-live` region. Announcement behavior can vary with the screen reader, browser, content, and custom rendering. Test the combinations your application supports; automated ARIA checks alone do not establish that the whole journey is understandable. ## prefers-reduced-motion react-tourlight respects the `prefers-reduced-motion` media query. When this preference is enabled: - Spotlight transitions between steps are instant (no animation) - Tooltip enter/exit animations are disabled - The overlay fades in without the smooth clip-path transition No configuration is needed. This behavior is automatic and CSS-based. ## Inert background content While the spotlight UI is active, off-path background content is marked with `inert`. For interactive steps, the target's subtree remains available. Inert content: - Cannot receive focus via Tab - Is excluded from the accessibility tree - Does not receive user click interactions This ensures that users navigating with a screen reader or keyboard stay within the tour context. ## Semantic button elements All interactive elements in the tooltip (Next, Previous, Skip, Done, Close) are rendered as semantic `

{step.title}

{step.content}

)} /> ``` The render function receives a `TooltipRenderProps` object: | Prop | Type | Description | |------|------|-------------| | `step` | `SpotlightStep` | The current step configuration | | `next` | `() => void` | Advance to the next step (or complete the tour on the last step) | | `previous` | `() => void` | Go back to the previous step | | `skip` | `() => void` | Skip the tour (fires `onSkip`) | | `close` | `() => void` | Stop the tour without marking it completed or skipped (the "×" action) | | `isFirst` | `boolean` | `true` on the first step | | `isLast` | `boolean` | `true` on the last step | | `currentIndex` | `number` | Zero-based index of the current step | | `totalSteps` | `number` | Total number of steps in the tour | ## i18n and custom labels Customize all button labels and the step counter format via the `labels` prop on `SpotlightProvider`: ```tsx `Schritt ${current} von ${total}`, }} > ``` All labels are optional -- any omitted label falls back to its English default. ## Overlay color Control the overlay background color (including opacity) via the `overlayColor` prop: ```tsx // Lighter overlay // Darker overlay // Tinted overlay ``` ## Spotlight padding and radius Adjust the padding and border radius of the spotlight cutout on a per-step basis: ```tsx { target: '#avatar', title: 'Your Profile', content: 'Click to update your profile picture.', spotlightPadding: 16, // extra space around the element spotlightRadius: 999, // fully circular cutout } ``` ## Transition duration Control how fast the spotlight and tooltip animate between steps: ```tsx // Faster transitions // Slower, more dramatic transitions // No animation ``` The default is `300ms`. Users with `prefers-reduced-motion: reduce` will see reduced or no animation regardless of this setting. --- # Tour documents (https://react-tourlight.vercel.app/docs/documents) One portable guide format for product people, developers, and agents. Tour documents are data-only JSON. Studio, the CLI, and agent tools use the same format; your application compiles it into regular `SpotlightStep` objects. The original React API remains available when you need React nodes, refs, or inline functions. ## A guide you can keep in Git ```json { "schemaVersion": 1, "id": "getting-started", "name": "Getting started", "steps": [ { "id": "create-project", "target": "[data-tour=\"create-project\"]", "title": "Make a little room for your idea", "content": "Create a project to bring your work together.", "placement": "bottom" } ] } ``` Document and step IDs are stable identifiers, not array positions. Keep them when changing copy or reordering steps. `schemaVersion` describes the file format, rather than a published revision of your guide. Prefer an app-owned target such as `data-tour="create-project"`. Generated class names and deeply nested selectors are likely to change when you redesign the page. ## Use a document in React ```tsx 'use client' import { SpotlightProvider, SpotlightTour, useSpotlight } from 'react-tourlight' import { compileTourDocument, parseTourDocument } from 'react-tourlight/document' import 'react-tourlight/styles.css' import guideJson from './getting-started.json' const guide = parseTourDocument(guideJson) const steps = compileTourDocument(guide) function StartGuide() { const { start } = useSpotlight() return } export function App() { return ( ) } ``` `parseTourDocument` accepts JSON text or an unknown JavaScript value, validates it, and returns a `TourDocument`. Invalid input throws `TourDocumentError`, whose `issues` contain a path, code, message, and severity. Use `validateTourDocument(value)` for a non-throwing `{ valid, issues }` result. `formatTourDocument(document)` produces stable formatted JSON for export and review. `createTourDocument({ id, name })` provides a starting document. Documents must fit within 4,000,000 UTF-8 bytes, both on import and when formatted for export. Formatting and escaped characters count toward the limit. The runtime validator also checks unique step IDs; JSON Schema validation alone does not cover every document invariant. ## Connect application behavior by name A JSON file cannot carry your React callbacks. Give it names that the application resolves through a registry: ```tsx import { compileTourDocument, type TourRegistry } from 'react-tourlight/document' const registry: TourRegistry = { actions: { openSettings: () => { setSettingsOpen(true) }, }, conditions: { isWorkspaceOwner: () => currentUser.role === 'owner', }, } const steps = compileTourDocument(guide, registry) ``` The functions in this example belong inside your application, where `setSettingsOpen` and `currentUser` are defined. Reference their names in a step: ```json { "id": "workspace-settings", "target": "[data-tour=\"settings-heading\"]", "title": "Make it your own", "content": "Your workspace settings live here.", "condition": "isWorkspaceOwner", "beforeStep": "openSettings" } ``` `condition` resolves from `registry.conditions`. `beforeStep`, `beforeShow`, `afterShow`, `onHide`, and `action.handler` resolve from `registry.actions`. Missing registry entries are reported during compilation. Your app decides which capabilities are available; imported JSON does not execute JavaScript or HTML. ## Supported step fields Every step requires `id`, `target`, `title`, and `content`. Content is plain text. Optional fields include: | Field | Purpose | | --- | --- | | `placement` | `top`, `bottom`, `left`, `right`, or `auto` | | `route` | Route used by the existing navigation integration | | `interactive` | Allow interaction with the target | | `advanceOn` | Advance on a real DOM event; `{ "event": "click" }` | | `timeout` | Target waiting timeout | | `spotlightPadding`, `spotlightRadius` | Adjust the highlight | | `disableOverlayClose` | Keep backdrop clicks from closing the step | | `action` | Button `{ "label": "Open settings", "handler": "openSettings" }` | | `condition` | Named condition that determines whether to show the step | | `beforeStep`, `beforeShow`, `afterShow`, `onHide` | Named lifecycle callbacks | See [multi-page tours](/docs/multi-page) for router configuration, and [testing](/docs/testing) for the distinction between a valid document and a working journey. --- # Checklists & guide launcher (https://react-tourlight.vercel.app/docs/guidance) Let people discover, start, and revisit useful guides at their own pace. A guide is useful beyond the first visit. `TourChecklist` gives people a short set of tasks to work through; `TourLauncher` offers a searchable library of guides. Try the [live component playground](/guidance). Both are optional components from `react-tourlight/guidance`. They use your existing `SpotlightProvider` and registered tours. They do not require a hosted service, analytics integration, or extra state store. ## Add a checklist ```tsx 'use client' import { SpotlightProvider, SpotlightTour } from 'react-tourlight' import { TourChecklist, TourLauncher, type GuideItem } from 'react-tourlight/guidance' import 'react-tourlight/styles.css' import 'react-tourlight/guidance.css' export function WorkspaceHelp({ projectCount }: { projectCount: number }) { const items: GuideItem[] = [{ id: 'create-project-task', tourId: 'create-project', title: 'Create your first project', description: 'A home for your next idea.', completed: projectCount > 0, }] return ( ) } ``` Wire the example's New project button to your own creation workflow and pass the resulting `projectCount` from application state. If the app already has a provider, mount these components inside it instead of introducing a second provider. ## Completion belongs to the application `completed` is controlled by you. Derive it from a meaningful result such as a saved project, an accepted invitation, or a configured preference. The components do not equate a tour's `onComplete` callback with successful task completion. Completed tasks remain available for replay. Starting a guide does not change its completion state, and clearing browser storage does not reset application-owned progress. Use your app's own persistence when tasks should follow a user across devices. ## Item and component props | Item field | Purpose | | --- | --- | | `id` | Stable checklist or library item ID | | `tourId` | ID of a tour registered inside the same provider | | `title` | The task or guide's human-readable name | | `description` | Optional supporting text; also searched by the launcher | | `completed` | Optional application-owned completion state | Both components accept `items`, `title`, `className`, and `onSelect`. `onSelect(item)` runs alongside starting the selected tour, so keep it lightweight; for example, send an event to your own analytics adapter. The checklist also accepts `description`. The launcher accepts `placeholder` and `emptyMessage`. Search filters the supplied titles and descriptions locally; it does not send queries to a server. Selection buttons are disabled while a guide is active. ## Match your interface The separate `guidance.css` stylesheet supplies the default appearance. Add `className` to scope your own CSS or override the `--tlg-bg`, `--tlg-ink`, `--tlg-muted`, `--tlg-line`, and `--tlg-accent` variables. Build your own launcher with `useSpotlight().start(tourId)` if you need a different interaction pattern. Register every referenced `tourId`, and test guides from each entry point. A checklist cannot make an unavailable target or an unauthorized application action valid; your application still determines what the user can see and do. --- # Headless Core (https://react-tourlight.vercel.app/docs/headless) Build a fully custom tour UI with react-tourlight/core — the unstyled engine with no CSS and no Floating UI. Most apps use `SpotlightProvider` + `SpotlightTour` and get a polished, accessible tour out of the box. But if you want to render your **own** overlay and tooltip -- to match a design system exactly, or to avoid shipping the default styles and Floating UI -- import the engine from `react-tourlight/core`. ## What's in `/core` The `react-tourlight/core` subpath exports the entire unstyled engine, with **no CSS, no default tooltip, and no Floating UI** in its module graph: - **`useTour`** -- a headless controller hook (the recommended entry point). - **State machine** -- `createTourStateMachine`. - **Element resolution / measurement** -- `resolveTarget`, `getTargetRect`, `measureElement`, `waitForElement`. - **Geometry** -- `generateClipPath`, `generateEmptyClipPath`. - **Focus / a11y** -- `createFocusTrap`, `setInert`, `getStepAriaLabel`, `createKeyboardHandler`, `scrollIntoView`. - **Route matching** -- `isRouteActive`, `getCurrentPath`. - **Persistence** -- `createMemoryStorage`, `loadPersistedTours`, `savePersistedTour`, `clearPersistedTour`, `isPersistedStateFresh`, `resolveStorage`. ```tsx import { useTour } from 'react-tourlight/core' // note: no `import 'react-tourlight/styles.css'` ``` The engine primitives and `useTour` are also re-exported from the main `react-tourlight` entry for convenience, but importing from `/core` keeps Floating UI and the default stylesheet out of your bundle. ## The `useTour` hook `useTour` drives the state machine, resolves and measures each step's target (async waiting, scrolling, and route-aware `navigate` included), and hands you everything needed to render: ```tsx import { useTour, type SpotlightStep } from 'react-tourlight/core' const steps: SpotlightStep[] = [ { target: '#search', title: 'Search', content: 'Find anything.' }, { target: '#profile', title: 'Profile', content: 'Your account.' }, ] function CustomTour() { const tour = useTour({ steps }) if (!tour.isActive) { return } // Still resolving the target (lazy element, navigation, etc.) if (!tour.rect) return null return ( <> {/* Your overlay — clipPath is generated for you */}
{/* Your tooltip, positioned from the measured rect */}

{tour.step?.title}

{tour.step?.content}
) } ``` ### Options ```ts interface UseTourOptions { steps: SpotlightStep[] onComplete?: () => void onSkip?: (stepIndex: number) => void onStateChange?: (state: TourState) => void initialState?: Partial // e.g. restored from persistence waitForElementTimeout?: number navigate?: (path: string) => void // route-aware steps isRouteActive?: (route: string, pathname: string) => boolean autoScroll?: boolean // default true } ``` ### Result ```ts interface UseTourResult { status: 'idle' | 'active' | 'completed' isActive: boolean currentIndex: number totalSteps: number step: SpotlightStep | null targetElement: HTMLElement | null rect: ElementRect | null // measured, padded, viewport-relative clipPath: string // ready-to-use CSS clip-path isResolving: boolean // active but target not yet found start(): void stop(): void next(): void previous(): void skip(): void goToStep(index: number): void } ``` ## Adding polish yourself Because you own the DOM, you also own accessibility. The same helpers the styled provider uses are available: ```tsx import { createFocusTrap, setInert, getStepAriaLabel } from 'react-tourlight/core' // Trap Tab focus inside your tooltip while it's open: const trap = createFocusTrap(tooltipEl) trap.activate() // ...later trap.deactivate() // Mark the rest of the page inert (pass the target to keep it interactive): const undo = setInert(tooltipEl /*, interactiveTarget */) // ...later undo() ``` ## Persistence in a headless setup Seed `useTour` from persisted state and write it back yourself: ```tsx import { useTour, createMemoryStorage, loadPersistedTours, savePersistedTour, } from 'react-tourlight/core' const storage = createMemoryStorage() // or window.localStorage const persisted = loadPersistedTours(storage, 'my-tours')['onboarding'] const tour = useTour({ steps, initialState: persisted, onStateChange: (state) => savePersistedTour(storage, 'my-tours', 'onboarding', state, steps.length), }) ``` ## Even lower level Skip `useTour` entirely and drive the state machine directly -- it's framework-agnostic (no React): ```ts import { createTourStateMachine, waitForElement, generateClipPath, getTargetRect } from 'react-tourlight/core' const machine = createTourStateMachine({ steps, onComplete: () => {} }) machine.subscribe((state) => console.log(state.status, state.currentStepIndex)) await machine.start() const el = await waitForElement('#lazy-button', { timeout: 8000 }) if (el) { const clipPath = generateClipPath(getTargetRect(el), 8, 8) } ``` --- # Single-Element Highlights (https://react-tourlight.vercel.app/docs/highlight) Highlight individual elements for "what's new" callouts and feature announcements without a full tour. Sometimes you don't need a multi-step tour. You just want to draw attention to a single element -- a new feature, a changed button, or an important action. react-tourlight provides two ways to do this. ## SpotlightHighlight component `` is a declarative component for one-off highlights. It shows a spotlight and tooltip around a single element: ```tsx import { useState } from 'react' import { SpotlightHighlight } from 'react-tourlight' function Dashboard() { const [showHighlight, setShowHighlight] = useState(true) return ( <> setShowHighlight(false)} /> ) } ``` ### Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `target` | `SpotlightTarget` | -- | CSS selector, React ref, or resolver function for the target element | | `title` | `string` | -- | Tooltip title | | `content` | `ReactNode` | -- | Tooltip content | | `active` | `boolean` | `true` | Whether the highlight is currently visible | | `placement` | `Placement` | `'auto'` | Tooltip placement relative to target | | `spotlightPadding` | `number` | -- | Padding around the spotlight cutout (px) | | `spotlightRadius` | `number` | -- | Border radius of the spotlight cutout (px) | | `onDismiss` | `() => void` | -- | Called when the highlight is dismissed | The highlight is automatically cleaned up when the component unmounts or when `active` is set to `false`. ## useSpotlightControl hook For imperative control, use `useSpotlightControl()`. This is useful when you need to trigger highlights from event handlers or effects: ```tsx import { useSpotlightControl } from 'react-tourlight' function FeatureAnnouncement() { const spotlight = useSpotlightControl() const showAnnouncement = () => { spotlight.highlight({ target: '#export-button', title: 'New: CSV Export', content: 'You can now export your data as CSV.', placement: 'bottom', }) } return } ``` Dismiss the highlight programmatically with `spotlight.dismissHighlight()`. ## Opt-in callouts with a beacon To let users discover a callout themselves, anchor a [beacon](/docs/beacons) to the element and pass it a `highlight`: ```tsx ``` ### SpotlightControl API | Method | Type | Description | |--------|------|-------------| | `highlight` | `(step: SpotlightStep) => void` | Show a spotlight on a single element | | `dismissHighlight` | `() => void` | Dismiss the active highlight | | `start` | `(tourId: string) => void` | Start a registered tour | | `stop` | `() => void` | Stop the active tour | | `next` | `() => void` | Go to the next step | | `previous` | `() => void` | Go to the previous step | | `skip` | `() => void` | Skip the current tour | | `goToStep` | `(index: number) => void` | Jump to a specific step | | `isActive` | `boolean` | Whether a tour or highlight is active | ## "What's new" callout pattern A common use case is showing a highlight once when a user first encounters a new feature. Combine `SpotlightHighlight` with `localStorage` to show it only once: ```tsx import { useState, useEffect } from 'react' import { SpotlightHighlight } from 'react-tourlight' function NewFeatureCallout() { const [show, setShow] = useState(false) useEffect(() => { const dismissed = localStorage.getItem('dismissed-export-callout') if (!dismissed) { setShow(true) } }, []) const handleDismiss = () => { setShow(false) localStorage.setItem('dismissed-export-callout', 'true') } return ( ) } ``` ## Dismissing highlights Highlights can be dismissed in several ways: - **Pressing Escape** (if `escToDismiss` is enabled on the provider, which it is by default) - **Clicking the overlay** (if `overlayClickToDismiss` is enabled, which it is by default) - **Clicking the close button** on the tooltip - **Programmatically** via `dismissHighlight()` from `useSpotlightControl` - **Setting `active={false}`** on `SpotlightHighlight` When a highlight is dismissed by the user (Escape, overlay click, or close button), the `onDismiss` callback is called, allowing you to update your state accordingly. --- # Introduction (https://react-tourlight.vercel.app/docs) Choose visual authoring, portable documents, or the React API to build your first guide. Tourlight includes a React player, a [visual Studio](/docs/studio), and [portable documents](/docs/documents) that your teammates and agents can edit. Use the player directly, or start with the [Studio playground](/studio). Studio, portable documents, guidance components, and the CLI require `react-tourlight` **0.5.0 or later**. You can also run the demo from source: ```bash pnpm install pnpm build pnpm --filter react-tourlight-docs dev ``` Run these from the repository root, then visit `/studio` on the local URL. ## Installation Install react-tourlight and its peer dependency: ```package-install npm install react-tourlight@^0.5.0 @floating-ui/react-dom ``` `@floating-ui/react-dom` is used for intelligent tooltip positioning (flip, shift, overflow handling). It's listed as a peer dependency to keep the core bundle small. ## Import the stylesheet react-tourlight ships a small CSS file for the overlay and transitions. Import it once at the root of your app: ```tsx import 'react-tourlight/styles.css' ``` ## Wrap your app in SpotlightProvider `SpotlightProvider` manages all tour and highlight state. Place it near the root of your component tree: ```tsx import { SpotlightProvider } from 'react-tourlight' import 'react-tourlight/styles.css' export default function App() { return ( ) } ``` ## Define your tour steps Each step targets a DOM element (via CSS selector or React ref), and includes a title, content, and optional placement: ```tsx import { SpotlightTour } from 'react-tourlight' const steps = [ { target: '#search-input', title: 'Search', content: 'Find anything instantly with our search.', placement: 'bottom' as const, }, { target: '[data-tour="sidebar"]', title: 'Navigation', content: 'Browse your projects and teams here.', placement: 'right' as const, }, { target: '#create-button', title: 'Create', content: 'Start a new project in one click.', placement: 'bottom' as const, }, ] // SpotlightTour registers the steps — it doesn't render any UI itself. ``` ## Start the tour Use the `useSpotlight` hook to start, stop, or control any registered tour: ```tsx import { useSpotlight } from 'react-tourlight' function OnboardingButton() { const { start } = useSpotlight() return ( ) } ``` ## Full working example Putting it all together: ```tsx import { SpotlightProvider, SpotlightTour, useSpotlight } from 'react-tourlight' import 'react-tourlight/styles.css' function App() { return ( console.log('Tour complete!')} /> ) } function Dashboard() { const { start } = useSpotlight() return (
) } ``` ## Next steps - [Visual Studio](/docs/studio) -- author guides in your integrated app - [Tour Documents](/docs/documents) -- a shared JSON artifact for your team - [Build with Agents](/docs/agents) -- skill, CLI, and MCP setup - [Testing & Diagnostics](/docs/testing) -- check targets and real journeys - [Multi-Step Tours](/docs/tour) -- step configuration, lifecycle hooks, and conditional steps - [Single-Element Highlights](/docs/highlight) -- one-off "what's new" callouts - [Customization](/docs/customization) -- themes, custom tooltips, and i18n - [API Reference](/docs/api-reference) -- full props and types reference --- # Interactive Steps (https://react-tourlight.vercel.app/docs/interactive) Let users interact with the real highlighted element and auto-advance the tour when they do. By default the overlay blocks interaction with the page so users focus on the tooltip. Sometimes you want the opposite: let the user actually click the button, type in the input, or drag the handle you're pointing at -- and advance the tour when they do. ## `interactive: true` Set `interactive: true` on a step and the spotlight hole becomes a genuine gap in the overlay. **All** pointer, keyboard, focus, and scroll events reach the highlighted element -- clicking, typing, hovering, dragging, and scrolling all work: ```tsx { target: '#search-input', title: 'Try searching', content: 'Go ahead — type something.', interactive: true, } ``` This is real event pass-through, not a synthesized click. Under the hood the overlay renders four transparent "blocker" rectangles around the target so the hole is a true gap in the pointer-capture surface. The dimmed backdrop and rounded spotlight cutout look identical to a normal step. Interactive steps also stay keyboard- and screen-reader-reachable: the target's subtree is kept out of the `inert` region that blocks the rest of the page. ## `advanceOn` — advance on real interaction Pair `interactive` with `advanceOn` to move the tour forward when a real DOM event fires on the target. This powers "click the actual button to continue" walkthroughs: ```tsx { target: '#new-project-btn', title: 'Create a project', content: 'Click the button to continue.', interactive: true, advanceOn: { event: 'click' }, } ``` `advanceOn` takes: | Field | Type | Description | |---|---|---| | `event` | `string` | The DOM event type to listen for on the target, e.g. `'click'`, `'input'`, `'submit'`, `'keydown'`. | | `selector` | `string?` | Optional. Only advance when the event originates from an element matching this selector inside the target. | A step with `advanceOn` is automatically treated as interactive, so you can omit `interactive: true` when you use it. ### Matching a descendant with `selector` When the target is a container, use `selector` to require the event to come from a specific child: ```tsx { target: '#toolbar', title: 'Add an item', content: 'Press the + button.', advanceOn: { event: 'click', selector: 'button.add' }, } ``` Only clicks on a `button.add` inside `#toolbar` advance the tour; clicks elsewhere in the toolbar don't. ## Composing with multi-page tours `advanceOn` composes with route steps. If the user clicks a real link that navigates to another page, the tour advances and the next step resumes on the destination route: ```tsx const steps = [ { target: '#go-to-billing', // a real title: 'Open billing', content: 'Click to continue.', interactive: true, advanceOn: { event: 'click' }, }, { target: '#invoices', title: 'Your invoices', content: 'Here they are.', route: '/billing', }, ] ``` See [Multi-Page Tours](/docs/multi-page) for the full navigation + persistence story. ## When *not* to use it For steps that are purely informational, leave `interactive` off. The blocking overlay keeps users on the guided path and prevents accidental clicks that could derail the tour. --- # Migrating from React Joyride (https://react-tourlight.vercel.app/docs/migration) Step-by-step guide to migrate from React Joyride to react-tourlight. If you're coming from React Joyride, this guide maps every concept to its react-tourlight equivalent. ## Why migrate? React Joyride is a solid, MIT-licensed library and, as of v3.2, works on React 19. People move to react-tourlight for what Joyride doesn't do: - **Headless core** — build your own tooltip and overlay on `react-tourlight/core` with no CSS and no Floating UI. - **Multi-page tours** — steps with a `route`, persisted across navigation and full reloads, resumed automatically. - **True interactive steps** — the spotlight hole is a real gap in the pointer surface, so typing, hovering, dragging, and scrolling reach the target. `advanceOn` continues the tour on a real click. - **Accessibility** — focus trap, `inert` on the rest of the page, live-region announcements, and focus restoration. - **Dark-mode-safe overlay** — a CSS `clip-path` cutout instead of `mix-blend-mode`. - **Server Components** — ships its own `"use client"` directive, so you can import it straight into `app/layout.tsx`. - **Smaller** — ~8 kB gzipped headless, ~19 kB styled. ## Concept mapping | React Joyride | react-tourlight | Notes | |---|---|---| | `` | `` | Tours require a unique `id` | | `run` prop | `useSpotlight().start(id)` | Imperative control via hook | | `continuous` prop | Always continuous | Step-by-step is the default | | `callback` with `STATUS` | `onComplete` / `onSkip` props | Cleaner event model | | `styles` prop | `theme` prop on Provider | Centralized theming | | `floaterProps` | Floating UI peer dep | Uses `@floating-ui/react-dom` | | `disableOverlay` | `overlayColor="transparent"` | Set overlay to transparent | | `spotlightPadding` | `spotlightPadding` on step | Same concept, per-step | | `locale` | `labels` prop on Provider | i18n for button text | | `tooltipComponent` | `renderTooltip` render prop | Full control via render props | ## Step-by-step migration ### 1. Install react-tourlight ```bash npm uninstall react-joyride npm install react-tourlight @floating-ui/react-dom ``` ### 2. Replace the provider **Before (Joyride):** ```tsx import Joyride from 'react-joyride' function App() { const [run, setRun] = useState(false) return ( <> { if (data.status === 'finished') handleComplete() }} /> ) } ``` **After (react-tourlight):** ```tsx import { SpotlightProvider, SpotlightTour, useSpotlight } from 'react-tourlight' import 'react-tourlight/styles.css' function App() { return ( handleComplete()} /> ) } function StartButton() { const { start } = useSpotlight() return } ``` ### 3. Convert step format **Before:** ```tsx const steps = [ { target: '.my-element', content: 'This is the first step', title: 'Step 1', placement: 'bottom', disableBeacon: true, }, ] ``` **After:** ```tsx const steps = [ { target: '.my-element', content: 'This is the first step', title: 'Step 1', placement: 'bottom', // Tours start directly; opt-in beacons are a separate component (see below) }, ] ``` Key differences: - No `disableBeacon` — tours start on the first step directly. If you want Joyride-style opt-in hotspots, render `` next to the element instead - `target` also accepts a React ref or a resolver function (`() => element`) - `content` accepts `ReactNode`, not just strings - `placement` supports `'auto'` for smart positioning - Add `spotlightPadding` and `spotlightRadius` per step ### 4. Update event handling **Before:** ```tsx callback={(data) => { const { status, type, index } = data if (status === 'finished') { /* done */ } if (status === 'skipped') { /* skipped */ } if (type === 'step:after') { /* step changed */ } }} ``` **After:** ```tsx { /* started */ }} onStepChange={(stepIndex, step) => { /* step changed */ }} onComplete={() => { /* done */ }} onSkip={(stepIndex) => { /* skipped at step */ }} /> ``` ### 5. Update custom tooltips **Before:** ```tsx tooltipComponent={({ step, primaryProps, backProps, skipProps, index, size }) => (

{step.title}

{step.content}

)} ``` **After:** ```tsx renderTooltip={({ step, next, previous, skip, close, isFirst, isLast }) => (

{step.title}

{step.content}

)} ``` ### 6. Replace beacons (optional) Joyride shows a pulsing beacon before each step unless `disableBeacon` is set. react-tourlight separates the two ideas: tours start directly, and `` is an explicit, opt-in hotspot you place where you want one: ```tsx import { SpotlightBeacon } from 'react-tourlight' ``` ## What you gain After migrating, you get: - **Headless core** — build a fully custom UI on the same engine - **Multi-page tours** — persist and resume across routes and reloads - **True interactive steps** — real event pass-through and `advanceOn` - **Better accessibility** — focus trap, `inert`, full keyboard nav, ARIA roles, screen reader support - **CSS clip-path overlay** — works in dark mode (no `mix-blend-mode` hacks) - **Async element support** — `MutationObserver`-based waiting for lazy-loaded content, for selectors, refs, and resolver functions --- # Multi-Page Tours (https://react-tourlight.vercel.app/docs/multi-page) Build tours that survive SPA navigation and full page reloads with built-in route-aware steps, navigation, and persistence. A tour can guide users across multiple routes -- pausing on one page, navigating to another, and resuming automatically. react-tourlight has this built in: tag steps with the `route` they live on, give the provider a `navigate` callback, and enable `persist`. ## The three pieces 1. **`route` on a step** -- the path this step's target lives on. 2. **`navigate` on the provider** -- how to change routes (your router's `push`, or `location.assign`). 3. **`persist` on the provider** -- save tour state so it survives a full page reload, and auto-resume on mount. ```tsx 'use client' import { useRouter } from 'next/navigation' import { SpotlightProvider, SpotlightTour } from 'react-tourlight' import 'react-tourlight/styles.css' export function Providers({ children }: { children: React.ReactNode }) { const router = useRouter() return ( router.push(path)}> {children} ) } ``` ## How `route` works When the tour advances to a step whose `route` doesn't match the current `window.location.pathname`, the provider: 1. Calls `navigate(step.route)`. 2. Waits (via the built-in `MutationObserver`) for the step's `target` to appear on the new page, up to `waitForElementTimeout`. 3. Shows the step once the target is found. For **SPA navigation** the component stays mounted the whole time. For a **full page load** (e.g. `navigate={(p) => location.assign(p)}`), the page unloads and reloads -- persistence takes over (see below). ### Route matching The default matcher compares `route` against the current pathname and supports: | Pattern | Matches | |---|---| | `/settings` | exactly `/settings` | | `/app/*` | `/app` and anything under it | | `/users/:id` | `/users/42`, `/users/abc` (segment counts must match) | Need something custom? Override it: ```tsx myMatcher(route, pathname)} /> ``` ## Persistence & resume `persist` saves tour state on every change and restores it on mount: ```tsx // localStorage (default) // custom storage (sessionStorage, memory, remote, ...) import { createMemoryStorage } from 'react-tourlight' ``` Any object matching the `SpotlightStorage` shape works -- `window.localStorage` and `window.sessionStorage` qualify as-is: ```ts interface SpotlightStorage { getItem: (key: string) => string | null setItem: (key: string, value: string) => void removeItem: (key: string) => void } ``` ### Auto-resume With `persist` on, a persisted **still-active** tour is automatically resumed on mount at the exact step it left off (`resume` defaults to `true`). This is what makes a full page reload seamless -- the tour reappears on the destination route. To require an explicit `start()` instead: ```tsx ``` ### Staleness Persisted snapshots are discarded when they're no longer safe to restore: - The tour definition's **step count changed** (you shipped a new version of the tour). - The snapshot is older than **`persistMaxAge`** (milliseconds), when set. ```tsx // 24h ``` ### Options summary | Prop | Default | Description | |---|---|---| | `persist` | `undefined` (off) | `true` for `localStorage`, or a custom `SpotlightStorage` | | `persistKey` | `'react-tourlight'` | Storage key | | `persistMaxAge` | `undefined` (never) | Discard snapshots older than N ms | | `resume` | `true` | Auto-resume a persisted active tour on mount | | `navigate` | `undefined` | Called with a step's `route` when it doesn't match | | `isRouteActive` | built-in matcher | Custom `(route, pathname) => boolean` | Persistence is completely opt-in. Without `persist`, nothing is written to storage and behavior is identical to previous versions. ## Advancing on real navigation Combine `advanceOn` (see [Interactive Steps](/docs/interactive)) with `route` steps to advance when the user clicks a real link: ```tsx const steps = [ { target: '#go-to-settings', // a real
link title: 'Open settings', content: 'Click here to continue.', interactive: true, advanceOn: { event: 'click' }, }, { target: '#profile-section', title: 'Profile', content: 'You made it!', route: '/settings', }, ] ``` The click navigates the app; the tour advances to the next step, which resumes on `/settings`. ## The `onBeforeStep` escape hatch For fully custom navigation logic, `onBeforeStep` runs before a step's target is resolved (and before any `route` navigation). It may be async and is awaited: ```tsx { target: '#report', title: 'Your report', content: 'Generated just now.', onBeforeStep: async () => { await openReportsDrawer() }, } ``` ## Escape hatch: `when` predicates (no `navigate`) If you'd rather not give react-tourlight a `navigate` callback, you can still build multi-page tours the manual way: define all steps up front and use `when` predicates to show only the steps relevant to the current route, navigating yourself via `onHide`. This predates the `route`/`navigate` API and remains fully supported. ```tsx import { usePathname, useRouter } from 'next/navigation' function OnboardingTour() { const pathname = usePathname() const router = useRouter() const steps = [ { target: '#quick-actions', title: 'Quick Actions', content: 'Next, the settings page.', when: () => pathname === '/dashboard', onHide: () => { if (pathname === '/dashboard') router.push('/settings') }, }, { target: '#profile-section', title: 'Profile', content: 'Update your profile here.', when: () => pathname === '/settings', }, ] return } ``` ## Works with any router react-tourlight never imports a router. Wire `navigate` (and optionally `isRouteActive`) to whatever you use: - **Next.js App Router** -- `const router = useRouter()` from `next/navigation`, `navigate={(p) => router.push(p)}` - **Next.js Pages Router** -- `useRouter()` from `next/router` - **React Router** -- `const navigate = useNavigate()` from `react-router-dom` - **TanStack Router** -- `const navigate = useNavigate()` from `@tanstack/react-router` - **No router** -- `navigate={(p) => location.assign(p)}` (relies on `persist` + auto-resume) See the [Next.js](/docs/recipes/nextjs) and [React Router](/docs/recipes/react-router) recipes for complete setups. --- # Self-hosting (https://react-tourlight.vercel.app/docs/self-hosting) Run Studio locally, embed it in your app, or host the documentation and playground yourself. Studio is a React component. It needs no Tourlight server, account, model API, or database. Drafts can stay in the current browser; exported JSON belongs in your own repository or storage. ## Run this checkout The new Studio and tooling live in this checkout until a release is published. From the repository root, with Node 24 and pnpm 11: ```bash pnpm install --frozen-lockfile pnpm build pnpm --filter react-tourlight-docs dev ``` Open `http://localhost:3000/studio`. The sample Fieldnotes app is interactive: create a project during a tour and see the result. The standalone playground edits documents and targets its sample app. It cannot inspect an arbitrary website by URL. ## Author inside your own app Use the [Studio integration](/docs/studio) on an app-owned authoring route. This gives product colleagues real elements to pick. Connect your router with `navigate` and keep the authoring shell mounted across route changes. Include portalled targets within the Studio child subtree; cross-origin frames and targets outside it are outside this editor's picking scope. Do not expose an authoring route to end users unintentionally. Your app owns any access control. The production player only needs the exported document and the React runtime; it does not need Studio. For a controlled editor, pass `value` and `onChange`. For browser drafts, use `defaultValue` with `storageKey`. Local storage is specific to a browser and origin: export before clearing browser data or moving hosts. It is not collaborative storage. ## Production Node server ```bash pnpm --filter react-tourlight-docs build pnpm --filter react-tourlight-docs start ``` Next.js also emits a standalone bundle. Copy static/public assets into it before starting: ```bash cp -R apps/docs/.next/static apps/docs/.next/standalone/apps/docs/.next/static cp -R apps/docs/public apps/docs/.next/standalone/apps/docs/public node apps/docs/.next/standalone/apps/docs/server.js ``` Set the process `PORT` and `HOSTNAME` to choose its listener. No application secrets are required. The documentation site currently requests its optional display fonts from Google Fonts; the editor package itself has system-font fallbacks and makes no network requests. Remove or replace those font links if your deployment must be fully offline. ## Container recipe A root `Dockerfile.studio` packages the documentation and playground with the standalone Node server: ```bash docker build -f Dockerfile.studio -t tourlight-studio . docker run --rm -p 3000:3000 tourlight-studio ``` The container serves authoring UI and documentation. It does not add shared draft storage, authentication, analytics, or remote MCP transport. Those are application concerns rather than hidden requirements of the package. --- # Visual Studio (https://react-tourlight.vercel.app/docs/studio) Build and edit guides in the application they explain. No Tourlight account required. Studio is the visual authoring surface included with Tourlight. It edits a portable [tour document](/docs/documents), previews through the same runtime you ship, and saves drafts in your browser. Try the [interactive Studio](/studio). ## Where it belongs A developer integrates Studio into an application they control. Product teammates then select targets, edit the guide, preview it, and export the document. Mount the editor on an authoring route that your application protects; end users only need the player. Studio's element picker works with the DOM of the integrated application. Entering an unrelated website URL does not grant access to that website. The public demo is a sample app you can experiment with, not a remote website editor. ## Add Studio to your app ```tsx 'use client' import { TourStudio } from 'react-tourlight/studio' import { parseTourDocument } from 'react-tourlight/document' import 'react-tourlight/styles.css' import 'react-tourlight/studio.css' import guideJson from './getting-started.json' import { YourApp } from './your-app' const guide = parseTourDocument(guideJson) export function GuideAuthoringPage() { return ( { // Connect an app-owned save workflow here if needed. }} > ) } ``` Studio includes its own preview provider. Importing the library's player or headless entry does not require importing Studio. Load the editor only on the routes where people author guides. For application state to control the document, pass `value` and `onChange` instead of `defaultValue`. Pass `registry` when the document references application actions or conditions; see [named capabilities](/docs/documents#connect-application-behavior-by-name). ## The authoring loop 1. **Start with a document.** Use an example, create a guide, or import an exported JSON file. 2. **Select the right element.** Pick a target in your integrated app. Stable `data-tour` anchors make guides easier to maintain. 3. **Write useful guidance.** Explain what the user can accomplish, then adjust step order and placement. 4. **Preview the journey.** Play it using the actual Tourlight runtime. Try the real interactions and routes your guide needs. 5. **Check the diagnostics.** A missing target can mean that the app is on a different route or that a menu needs to be open. Inspect the message before changing the selector. 6. **Export and review.** Keep the JSON in your repository or app-owned storage, then compile it into your player integration. Browser drafts are local to that browser and origin. Export important work. Clearing site data removes locally stored drafts; a draft is not a shared team database or a published production guide. ## Host it yourself Studio is a React component, so it runs wherever your application runs. Embed it in your own React app, or run this repository's documentation app to use the included playground. There is no required Tourlight backend, sign-in service, or hosted configuration endpoint. If you need shared drafts, permissions, publishing, or review workflows, connect them through your own application and `onChange`. Exporting a document does not publish it to your users automatically. ## Keep the code-first escape hatch Portable documents support text and named application behavior. Continue using `SpotlightTour` directly when a tour needs arbitrary React content, refs, or custom rendering. You do not need to convert existing tours to documents to use this release. --- # Testing & diagnostics (https://react-tourlight.vercel.app/docs/testing) Separate a valid guide document, a ready target, and a successful real-user journey. Use three layers of checks. Each answers a different question. | Check | What it establishes | | --- | --- | | Document validation | The guide follows the supported format | | Target diagnostics | A selector resolves in the current page state | | Browser scenario | The tested user, routes, and interactions work together | Passing one layer does not imply the others passed. ## Validate the document ```tsx import { validateTourDocument } from 'react-tourlight/document' const result = validateTourDocument(importedJson) if (!result.valid) { // Show result.issues near the corresponding fields in your editor. } ``` Issues include `path`, `code`, `message`, and `severity`. Validation checks supported fields and types; compilation additionally resolves named registry actions and conditions. Runtime callbacks remain your application's responsibility. From a built source checkout: ```bash node scripts/tourlight.mjs validate welcome.tour.json ``` ## Inspect targets in the browser ```tsx import { inspectTourTargets } from 'react-tourlight/diagnostics' const diagnostics = inspectTourTargets(guide, document, window.location.pathname) ``` Each result includes `stepId`, `target`, `matches`, `status`, and `message`. | Status | Meaning | | --- | --- | | `ready` | One visible, measurable element matched | | `missing` | No target matched in this page state | | `hidden` | The target exists but is hidden or has no measurable size | | `ambiguous` | More than one element matched | | `invalid` | The CSS selector could not be parsed | | `other-route` | Inspect this target on its configured route | These are snapshots. A `ready` result does not establish that the element is unobscured, enabled, usable by every input method, or still present a moment later. A closed dialog's target can be missing until the app opens it. Do not “fix” that by changing the selector to an unrelated visible element. `suggestTourTarget(element)` prefers a unique `data-tour`, then `data-testid`, then an ID. It returns `null` when no supported stable anchor can identify the element uniquely. Add a deliberate anchor to your application in that case. ## Generate a Playwright starting point The CLI can generate target smoke checks from the document: ```bash node scripts/tourlight.mjs test welcome.tour.json \ --base-url http://localhost:3000 > welcome.tour.spec.ts ``` This prints source code. It does not launch the app, install Playwright, or run a test. Review the output and run it within your application's configured Playwright setup: ```bash pnpm exec playwright test welcome.tour.spec.ts ``` Generated checks establish basic target uniqueness and visibility. Steps that need preparation hooks, conditions, or dynamic route parameters may be marked skipped. Supply the right authenticated state, route values, and setup, then replace those skips with meaningful assertions. Keep the app running while you execute the tests. ## Test the actual journey A complete scenario should launch the guide, perform its real actions, verify step progression, and assert the resulting application state. Include the variations your application supports: - Initial launch, replay, dismissal, and focus restoration. - Next, back, interactive steps, and the application's success or failure response. - Route transitions, delayed data, and persisted resume. - Missing targets, removed elements, and cancellation during a wait. - Keyboard navigation, narrow viewports, and reduced motion. For example, an “Invite a teammate” test should assert that the invitation appears in your application's test data or confirmation UI. Reaching the last tooltip only establishes that the guide ended. ## Keep evidence useful Run your checks against controlled test accounts and environments. Report which guide version, viewport, route, and role were exercised, and which steps were skipped. A failed target check should point to the guide's stable step ID so a product teammate can find it in Studio. --- # Multi-Step Tours (https://react-tourlight.vercel.app/docs/tour) Build guided walkthroughs with multi-step tours, lifecycle hooks, conditional steps, and interactive elements. ## Defining a tour A tour is a sequence of steps registered with ``. Each step highlights a target element and displays a tooltip with a title and content. ```tsx import { SpotlightTour } from 'react-tourlight' const steps = [ { target: '#dashboard-header', title: 'Welcome', content: 'This is your dashboard. Let us show you around.', placement: 'bottom', }, { target: '#analytics-panel', title: 'Analytics', content: 'Track your key metrics in real time.', placement: 'right', }, { target: '#settings-button', title: 'Settings', content: 'Customize your workspace from here.', placement: 'left', }, ] function App() { return ( console.log('Done!')} onSkip={(stepIndex) => console.log(`Skipped at step ${stepIndex}`)} /> ) } ``` `` doesn't render any UI itself. It registers the steps with the nearest ``, making them available to start via `useSpotlight().start('dashboard-tour')`. ## Step configuration Every step requires `target`, `title`, and `content`. Everything else is optional. ```tsx const step: SpotlightStep = { // Required target: '#my-element', // CSS selector, React ref, or () => element title: 'Feature Name', content: 'Description of the feature.', // Positioning placement: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | 'auto' spotlightPadding: 8, // padding around the cutout (px) spotlightRadius: 8, // border radius of the cutout (px) // Interactivity interactive: false, // let users click the highlighted element disableOverlayClose: false, // prevent overlay click from dismissing // CTA button action: { label: 'Try it now', onClick: () => openFeature(), }, // Conditional display when: () => userHasPermission(), // skip step if returns false // Lifecycle hooks onBeforeShow: () => loadData(), // called before step is shown (can be async) onAfterShow: () => trackView(), // called after step is visible onHide: () => cleanup(), // called when step is hidden } ``` ## Using React refs as targets Instead of CSS selectors, you can use refs. This is useful when elements don't have stable IDs or `data-` attributes: ```tsx import { useSpotlightTarget, SpotlightTour } from 'react-tourlight' function Dashboard() { const searchRef = useSpotlightTarget() const navRef = useSpotlightTarget() return ( <> ) } ``` ## Using resolver functions as targets When neither a selector nor a ref can reach the element — shadow DOM, an iframe you control, a canvas-backed UI, a third-party widget — pass a function that returns the element (or `null` while it doesn't exist yet). It is re-run on every DOM mutation until it resolves or the step's `timeout` elapses, exactly like a selector: ```tsx const steps = [ { target: () => document.querySelector('my-widget')?.shadowRoot?.querySelector('#cta') ?? null, title: 'Inside the widget', content: 'Targets can live in shadow roots.', }, { target: () => gridApi.getCellElement({ row: 0, col: 'status' }), title: 'Status column', content: 'Or come from a library API.', }, ] ``` A resolver that throws is treated as "not found yet", so it's safe to chain optional lookups. ## Starting at a specific step `start()` accepts an options object. Use `stepIndex` for deep links, "resume where you left off" buttons, or a help-menu entry that jumps straight to the relevant part of a tour: ```tsx const { start } = useSpotlight() ``` An explicit `stepIndex` overrides any persisted position for that tour. Out-of-range values are ignored and the tour starts from the beginning. ## Starting and stopping tours Use the `useSpotlight` hook to control tours programmatically: ```tsx import { useSpotlight } from 'react-tourlight' function TourControls() { const { start, stop, next, previous, skip, isActive, currentStep, totalSteps } = useSpotlight() return (
{!isActive ? ( ) : ( <>

Step {currentStep + 1} of {totalSteps}

)}
) } ``` You can also jump to a specific step by index: ```tsx const { goToStep } = useSpotlight() goToStep(2) // jump to the third step ``` ## Tour lifecycle callbacks Handle tour completion and skip events at both the provider and tour level: ```tsx // Provider-level — fires for any tour { console.log(`Tour "${tourId}" completed`) }} onSkip={(tourId, stepIndex) => { console.log(`Tour "${tourId}" skipped at step ${stepIndex}`) }} > {/* ... */} // Tour-level — fires for this specific tour track('tour_started')} onStepChange={(stepIndex, step) => track('tour_step_viewed', { stepIndex, title: step.title })} onComplete={() => markOnboardingDone()} onSkip={(stepIndex) => trackDropoff(stepIndex)} /> ``` `onStepChange` fires exactly once each time a different step becomes visible (including the first), which makes step-level funnels trivial without decoding `TourState`. The provider-level equivalents receive the `tourId` as their first argument: `onStart(tourId)` and `onStepChange(tourId, stepIndex, step)`. ## Conditional steps Use the `when` predicate to conditionally include or skip steps. The step is skipped if `when` returns `false`. This can be synchronous or asynchronous: ```tsx const steps: SpotlightStep[] = [ { target: '#basic-feature', title: 'Basic Feature', content: 'Available to everyone.', }, { target: '#admin-panel', title: 'Admin Panel', content: 'Manage users and permissions.', when: () => currentUser.role === 'admin', }, { target: '#beta-feature', title: 'Beta Feature', content: 'Try out our latest experiment.', when: async () => { const flags = await fetchFeatureFlags() return flags.betaEnabled }, }, ] ``` ## Step lifecycle hooks Each step can define hooks that run at specific points in its lifecycle: ```tsx { target: '#data-table', title: 'Data Table', content: 'Your latest data, refreshed.', // Runs before the step is shown. Can be async — the step // waits for the promise to resolve before appearing. onBeforeShow: async () => { await fetchLatestData() }, // Runs after the step tooltip is visible on screen. onAfterShow: () => { analytics.track('tour_step_viewed', { step: 'data-table' }) }, // Runs when the user leaves this step (next, previous, skip, or close). onHide: () => { resetTableFilters() }, } ``` ## Interactive steps By default, the overlay prevents interaction with background elements. Set `interactive: true` to allow users to click and interact with the highlighted element: ```tsx { target: '#theme-toggle', title: 'Try Dark Mode', content: 'Click the toggle to switch themes.', interactive: true, } ``` This is useful for steps that ask users to perform an action (toggle a switch, click a button, type in an input) as part of the tour. ## Action buttons Add a custom CTA button inside the tooltip with the `action` property: ```tsx { target: '#invite-button', title: 'Invite Your Team', content: 'Collaboration works best with teammates.', action: { label: 'Invite Now', onClick: () => openInviteModal(), }, } ``` The action button appears alongside the default navigation buttons (Next, Previous, Skip). --- # Troubleshooting & FAQ (https://react-tourlight.vercel.app/docs/troubleshooting) Common issues, browser compatibility, and frequently asked questions. ## Common issues ### Spotlight doesn't appear 1. **Missing CSS import** — Make sure you import the stylesheet: ```tsx import 'react-tourlight/styles.css' ``` 2. **Tour not started** — `` only registers steps. You need to call `start()`: ```tsx const { start } = useSpotlight() start('your-tour-id') ``` 3. **Target element not found** — Verify the CSS selector or ref points to a mounted element. Open DevTools and run `document.querySelector('#your-selector')` to confirm. 4. **Missing Provider** — Ensure `` wraps both your `` and the component calling `useSpotlight()`. ### Tooltip is mispositioned 1. **Install Floating UI** — `@floating-ui/react-dom` is a **required** peer dependency; react-tourlight imports it directly and has no positioning fallback without it. If it's missing (or your package manager didn't install peer deps automatically), install it explicitly: ```bash npm install @floating-ui/react-dom ``` 2. **Target inside a scroll container** — Floating UI handles this automatically via `autoUpdate`. If positioning still looks wrong, ensure the target element is actually visible in the viewport. 3. **CSS transforms on ancestors** — CSS `transform` on a parent element creates a new containing block, which can offset fixed positioning. This is a browser behavior, not a bug. Floating UI handles this correctly. ### Tour doesn't advance 1. **Async elements** — If the next step's target isn't in the DOM yet, react-tourlight waits for it using `MutationObserver`. Ensure the element eventually mounts. Check the `when` callback if you're using conditional steps. 2. **Focus trap interference** — If another focus trap (e.g., a modal) is active, it may conflict with the spotlight's focus trap. Dismiss the modal before starting the tour. ### SSR / hydration issues react-tourlight is client-only. The overlay and tooltip render via portals and depend on `document`. In Next.js or other SSR frameworks: ```tsx // Dynamically import if needed import dynamic from 'next/dynamic' const SpotlightProvider = dynamic( () => import('react-tourlight').then(m => m.SpotlightProvider), { ssr: false } ) ``` Or simply ensure `start()` is only called after hydration (e.g., in a `useEffect`), which is the normal pattern. ## Browser compatibility | Browser | Minimum version | Notes | |---|---|---| | Chrome | 88+ | Full support | | Firefox | 97+ | Full support | | Safari | 15.4+ | Full support | | Edge | 88+ | Full support (Chromium-based) | | iOS Safari | 15.4+ | Full support | | Chrome Android | 88+ | Full support | react-tourlight uses: - CSS `clip-path: path()` (widely supported) - `MutationObserver` (supported everywhere) - `inert` attribute (Chrome 102+, Firefox 112+, Safari 15.5+) For older browsers without `inert` support, the accessibility features degrade gracefully — keyboard navigation and ARIA attributes still work, but background content won't be fully inert. ## FAQ ### Does it work with React Native? No. react-tourlight is for React DOM (web) only. It relies on DOM APIs like `getBoundingClientRect`, CSS `clip-path`, and portal rendering. ### Can I use it without Floating UI? No. `@floating-ui/react-dom` is a **required** peer dependency — the tooltip is positioned entirely through Floating UI's `useFloating` hook (flip, shift, and overflow handling included), and there is no built-in fallback positioning algorithm. Installing it alongside react-tourlight is a required step, not an optional enhancement. ### Does it support multiple simultaneous tours? Only one tour can be active at a time. You can register multiple tours with different IDs and start them independently, but starting a new tour will stop the current one. ### Can I persist tour completion state? react-tourlight doesn't handle persistence — that's your app's concern. Use the `onComplete` callback to save state: ```tsx localStorage.setItem('onboarding-done', 'true')} /> ``` ### Does it work with CSS-in-JS libraries? Yes. The default styles are in a regular CSS file (`react-tourlight/styles.css`). You can override styles via CSS custom properties, the `theme` prop, or the `renderTooltip` render prop for full control. ### What's the bundle size? Measured on the published build (gzip): | What you import | Size | |---|---| | `react-tourlight/core` (headless engine only) | ~8 kB | | `react-tourlight` + `react-tourlight/styles.css` (styled default UI) | ~19 kB | | `@floating-ui/react-dom` peer dependency | ~3 kB | The engine chunk is shared between the two entries, so importing both doesn't double-count it. `@floating-ui/react-dom` is a peer dependency so it's installed once and shared with anything else in your app that already uses Floating UI. --- # Tracking Tour Completion (https://react-tourlight.vercel.app/docs/recipes/analytics) Track tour completion, skip events, and step-level drop-off with analytics providers. Understanding how users interact with your tours helps you improve onboarding flows. react-tourlight provides callbacks at both the provider and tour level that integrate with any analytics service. ## Basic tracking with callbacks Use `onComplete` and `onSkip` on `` to track all tours globally, or on `` for specific tours: ```tsx { console.log(`Tour "${tourId}" completed`) }} onSkip={(tourId, stepIndex) => { console.log(`Tour "${tourId}" skipped at step ${stepIndex}`) }} > {/* ... */} ``` ## Tracking with onStateChange For detailed step-level tracking, use `onStateChange`. It fires on every state transition -- start, step change, complete, and skip: ```tsx import type { TourState } from 'react-tourlight' { // state.status is 'idle' | 'active' | 'completed' // state.currentStepIndex tells you which step the user is on // state.seenSteps tracks which steps were viewed // state.completedAt / state.skippedAt provide timestamps if (state.status === 'active') { analytics.track('tour_step_viewed', { tourId, stepIndex: state.currentStepIndex, seenSteps: state.seenSteps.length, }) } }} > ``` ## localStorage persistence Track whether users have completed tours so you don't show them again: ```tsx import { useState } from 'react' import { SpotlightProvider, SpotlightTour, useSpotlight } from 'react-tourlight' import type { TourState } from 'react-tourlight' function App() { const [tourState, setTourState] = useState>(() => { try { const stored = localStorage.getItem('tour-state') return stored ? JSON.parse(stored) : {} } catch { return {} } }) const handleStateChange = (tourId: string, state: TourState) => { setTourState((prev) => { const next = { ...prev, [tourId]: state } localStorage.setItem('tour-state', JSON.stringify(next)) return next }) } // Don't start the tour if already completed const isCompleted = tourState['onboarding']?.status === 'completed' return ( {!isCompleted && } ) } function AutoStartTour() { const { start } = useSpotlight() // Start the tour on mount useEffect(() => { start('onboarding') }, [start]) return null } ``` ## PostHog ```tsx import posthog from 'posthog-js' { posthog.capture('tour_completed', { tour_id: tourId }) }} onSkip={(tourId, stepIndex) => { posthog.capture('tour_skipped', { tour_id: tourId, skipped_at_step: stepIndex, }) }} onStateChange={(tourId, state) => { if (state.status === 'active') { posthog.capture('tour_step_viewed', { tour_id: tourId, step_index: state.currentStepIndex, }) } }} > ``` ## Amplitude ```tsx import * as amplitude from '@amplitude/analytics-browser' { amplitude.track('Tour Completed', { tourId }) }} onSkip={(tourId, stepIndex) => { amplitude.track('Tour Skipped', { tourId, stepIndex }) }} onStateChange={(tourId, state) => { if (state.status === 'active') { amplitude.track('Tour Step Viewed', { tourId, stepIndex: state.currentStepIndex, }) } }} > ``` ## Mixpanel ```tsx import mixpanel from 'mixpanel-browser' { mixpanel.track('Tour Completed', { tour_id: tourId }) }} onSkip={(tourId, stepIndex) => { mixpanel.track('Tour Skipped', { tour_id: tourId, skipped_at_step: stepIndex, }) }} onStateChange={(tourId, state) => { if (state.status === 'active') { mixpanel.track('Tour Step Viewed', { tour_id: tourId, step_index: state.currentStepIndex, }) } }} > ``` ## Tracking step-level drop-off To understand where users abandon tours, compare `seenSteps` against total steps in the `onSkip` callback: ```tsx { analytics.track('tour_abandoned', { tourId: 'onboarding', abandonedAtStep: stepIndex, totalSteps: steps.length, completionRate: stepIndex / steps.length, // Which steps did they see before dropping off? stepsViewed: stepIndex + 1, }) }} onComplete={() => { analytics.track('tour_completed', { tourId: 'onboarding', totalSteps: steps.length, }) }} /> ``` This data helps you identify steps that are confusing, too long, or not valuable to users -- so you can iterate on your onboarding flow. --- # Next.js Integration (https://react-tourlight.vercel.app/docs/recipes/nextjs) Set up react-tourlight with Next.js App Router, including client component wrappers and SSR considerations. react-tourlight is a client-side library that uses browser APIs (`MutationObserver`, `document.querySelector`, `inert`). As of v0.2.0, react-tourlight's published entry point ships its own `"use client"` directive. That means `SpotlightProvider` and `SpotlightTour` can be imported directly into a Server Component file (like `app/layout.tsx`) and rendered there without you writing a wrapper — Next.js reads the client boundary from inside the package itself. The wrapper pattern below is still useful if you want a single place to configure the provider (theme, labels, callbacks) or to keep `app/layout.tsx` free of react-tourlight imports, but it's no longer required just to satisfy the Server/Client Component boundary. ## App Router setup ### 1. (Optional) Create a client wrapper Create a `SpotlightWrapper` component marked with `'use client'`: ```tsx title="components/spotlight-wrapper.tsx" 'use client' import { SpotlightProvider } from 'react-tourlight' import 'react-tourlight/styles.css' export function SpotlightWrapper({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### 2. Add to your root layout Import the wrapper in your root layout. The layout itself can remain a Server Component: ```tsx title="app/layout.tsx" import { SpotlightWrapper } from '@/components/spotlight-wrapper' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### 3. Define tours in client components Tour definitions and the `useSpotlight` hook must be used in Client Components: ```tsx title="components/onboarding-tour.tsx" 'use client' import { SpotlightTour, useSpotlight } from 'react-tourlight' export function OnboardingTour() { return ( { localStorage.setItem('onboarding-done', 'true') }} /> ) } export function StartTourButton() { const { start } = useSpotlight() return ( ) } ``` ## SSR considerations react-tourlight renders nothing on the server. The overlay, tooltip, and spotlight cutout are all client-side only. This means: - No hydration mismatch issues -- the spotlight UI is only rendered in the browser - Server Components can render target elements (`#search`, `#sidebar`, etc.) normally - Tour state and `useSpotlight` are only available in Client Components ## Dynamic import pattern If you want to code-split the tour logic out of your initial bundle, use `next/dynamic`: ```tsx title="components/lazy-tour.tsx" 'use client' import dynamic from 'next/dynamic' const OnboardingTour = dynamic( () => import('./onboarding-tour').then((mod) => ({ default: mod.OnboardingTour })), { ssr: false } ) export function LazyOnboardingTour() { return } ``` This keeps the tour code out of the initial JavaScript bundle. The `ssr: false` option ensures it only loads on the client. ## Multi-page tours with App Router For tours that span multiple routes, wire `router.push` to the provider's `navigate` prop, enable `persist`, and tag each step with the `route` it lives on. Put this in your client wrapper so the whole app is covered: ```tsx title="components/spotlight-wrapper.tsx" 'use client' import { useRouter } from 'next/navigation' import { SpotlightProvider, SpotlightTour } from 'react-tourlight' import 'react-tourlight/styles.css' export function SpotlightWrapper({ children }: { children: React.ReactNode }) { const router = useRouter() return ( router.push(path)}> {children} ) } ``` When the tour advances to the settings step while on `/dashboard`, react-tourlight calls `router.push('/settings')` and waits for `#settings-panel` before showing the step. With `persist`, a full reload mid-tour resumes automatically. Dynamic segments work too -- `route: '/projects/:id'` matches `/projects/42`. ### Alternative: `when` predicates (no `navigate`) If you'd rather navigate yourself, you can still use `usePathname()` with `when` predicates and `onHide`: ```tsx title="components/onboarding-tour.tsx" 'use client' import { usePathname } from 'next/navigation' import { SpotlightTour } from 'react-tourlight' export function OnboardingTour() { const pathname = usePathname() return ( pathname === '/', }, { target: '#settings-panel', title: 'Settings', content: 'Configure your workspace.', when: () => pathname === '/settings', }, ]} /> ) } ``` See the [Multi-Page Tours](/docs/multi-page) guide for the complete route + persistence reference. --- # React Router (https://react-tourlight.vercel.app/docs/recipes/react-router) Wire react-tourlight to React Router for multi-page, route-aware tours with persistence. react-tourlight works with any router. This recipe shows a complete multi-page setup with **React Router** (v6 / v7 / Remix data router APIs). ## Setup Put `SpotlightProvider` inside your router (so `useNavigate` is available) but above your routes. A layout route is the natural place: ```tsx title="src/routes/root.tsx" import { Outlet, useNavigate } from 'react-router-dom' import { SpotlightProvider, SpotlightTour } from 'react-tourlight' import 'react-tourlight/styles.css' export function RootLayout() { const navigate = useNavigate() return ( navigate(path)} > ) } ``` Register the layout as a parent route so it wraps every page: ```tsx title="src/main.tsx" import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { RootLayout } from './routes/root' import { Dashboard } from './routes/dashboard' import { Settings } from './routes/settings' const router = createBrowserRouter([ { element: , children: [ { path: '/dashboard', element: }, { path: '/settings', element: }, ], }, ]) export function App() { return } ``` That's it. When the tour advances to the `/settings` step while on `/dashboard`, react-tourlight calls `navigate('/settings')` and waits for `#settings-profile` to appear before showing the step. ## Starting the tour Use `useSpotlight` anywhere inside the provider: ```tsx import { useSpotlight } from 'react-tourlight' function HelpButton() { const { start } = useSpotlight() return } ``` ## Dynamic route segments React Router path params map cleanly onto the built-in matcher's `:param` syntax: ```tsx { target: '#project-settings', title: 'Project settings', content: 'Configure this project.', route: '/projects/:id/settings', } ``` `/projects/:id/settings` matches `/projects/42/settings`, `/projects/abc/settings`, etc. For anything the built-in matcher can't express, pass your own: ```tsx import { matchPath } from 'react-router-dom' matchPath(route, pathname) !== null} navigate={navigate} /> ``` ## Surviving a full reload With `persist` enabled and `resume` on (the default), a tour that's mid-flight when the user reloads the page is automatically restored at the step it left off -- the layout remounts, the persisted state is read, and the tour reappears. No extra code required. If you'd rather not auto-resume, set `resume={false}` and call `start('onboarding')` yourself when appropriate. ## See also - [Multi-Page Tours](/docs/multi-page) -- the full route + persistence reference. - [Interactive Steps](/docs/interactive) -- advance the tour when the user clicks a real link. --- # Remix Integration (https://react-tourlight.vercel.app/docs/recipes/remix) Set up react-tourlight with Remix, including client-only rendering considerations. react-tourlight uses browser APIs and must run on the client. Remix supports this through its `ClientOnly` wrapper pattern and `*.client.tsx` file convention. ## Basic setup ### 1. Create the provider wrapper ```tsx title="app/components/spotlight-wrapper.tsx" import { SpotlightProvider } from 'react-tourlight' import 'react-tourlight/styles.css' export function SpotlightWrapper({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### 2. Add to your root route Wrap your app in the provider inside `app/root.tsx`: ```tsx title="app/root.tsx" import { Outlet } from '@remix-run/react' import { SpotlightWrapper } from '~/components/spotlight-wrapper' export default function App() { return ( {/* ... */} ) } ``` ### 3. Define tours in route components ```tsx title="app/routes/dashboard.tsx" import { SpotlightTour, useSpotlight } from 'react-tourlight' export default function Dashboard() { const { start } = useSpotlight() return (
{/* ... */}
{/* ... */}
) } ``` ## Client-only rendering If you encounter hydration issues (unlikely, since react-tourlight renders no server markup), you can use Remix's `ClientOnly` utility: ```tsx title="app/components/client-only-tour.tsx" import { ClientOnly } from 'remix-utils/client-only' import { SpotlightTour } from 'react-tourlight' import type { SpotlightStep } from 'react-tourlight' const steps: SpotlightStep[] = [ { target: '#feature', title: 'New Feature', content: 'Check this out.', placement: 'bottom', }, ] export function ClientOnlyTour() { return ( {() => ( )} ) } ``` Alternatively, use the `.client.tsx` file suffix to ensure the module is only loaded on the client: ```tsx title="app/components/tour.client.tsx" import { SpotlightTour } from 'react-tourlight' export function Tour() { return ( ) } ``` ## Multi-page tours with Remix Use `useLocation` from `@remix-run/react` for route-aware steps: ```tsx import { useLocation } from '@remix-run/react' import { SpotlightTour } from 'react-tourlight' export function OnboardingTour() { const location = useLocation() return ( location.pathname === '/dashboard', }, { target: '#settings-form', title: 'Settings', content: 'Configure your preferences.', when: () => location.pathname === '/settings', }, ]} /> ) } ``` See the [Multi-Page Tours](/docs/multi-page) guide for a full persistence setup. --- # shadcn/ui Themed Tooltip (https://react-tourlight.vercel.app/docs/recipes/shadcn) Build a custom tooltip styled with shadcn/ui components to match your app's design system. react-tourlight's `renderTooltip` prop gives you full control over the tooltip UI. This recipe shows how to build a tooltip using shadcn/ui's `Card` and `Button` components so your tour matches the rest of your app. ## Custom tooltip component First, create a reusable tooltip component using shadcn/ui primitives: ```tsx title="components/spotlight-tooltip.tsx" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import type { TooltipRenderProps } from 'react-tourlight' export function ShadcnTooltip({ step, next, previous, skip, currentIndex, totalSteps, }: TooltipRenderProps) { const isFirstStep = currentIndex === 0 const isLastStep = currentIndex === totalSteps - 1 const progress = ((currentIndex + 1) / totalSteps) * 100 return (
{step.title} {currentIndex + 1} / {totalSteps}
{step.content}
{!isFirstStep && ( )}
) } ``` ## Using the custom tooltip Pass your component to the `renderTooltip` prop on ``: ```tsx title="components/onboarding-tour.tsx" import { SpotlightTour } from 'react-tourlight' import { ShadcnTooltip } from './spotlight-tooltip' export function OnboardingTour() { return ( } /> ) } ``` ## Adding an action button If a step has an `action` property, you can render it as an additional button: ```tsx title="components/spotlight-tooltip.tsx" {step.content} {step.action && ( )} ``` ## Matching dark mode If your app uses shadcn/ui's dark mode (via `class` strategy on ``), set the spotlight provider to `theme="auto"` or `theme="dark"` so the overlay matches. The tooltip itself inherits colors from your shadcn/ui theme through Tailwind's dark mode classes: ```tsx } /> {/* ... */} ``` Since the custom tooltip uses shadcn/ui components, it automatically picks up your `dark:` Tailwind classes -- no extra theme configuration needed.