react-tourlight

Multi-Step Tours

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 <SpotlightTour>. Each step highlights a target element and displays a tooltip with a title and content.

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 (
    <SpotlightTour
      id="dashboard-tour"
      steps={steps}
      onComplete={() => console.log('Done!')}
      onSkip={(stepIndex) => console.log(`Skipped at step ${stepIndex}`)}
    />
  )
}

<SpotlightTour> doesn't render any UI itself. It registers the steps with the nearest <SpotlightProvider>, making them available to start via useSpotlight().start('dashboard-tour').

Step configuration

Every step requires target, title, and content. Everything else is optional.

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:

import { useSpotlightTarget, SpotlightTour } from 'react-tourlight'

function Dashboard() {
  const searchRef = useSpotlightTarget()
  const navRef = useSpotlightTarget()

  return (
    <>
      <SpotlightTour
        id="onboarding"
        steps={[
          { target: searchRef, title: 'Search', content: 'Find anything.' },
          { target: navRef, title: 'Navigation', content: 'Browse projects.' },
        ]}
      />
      <input ref={searchRef} placeholder="Search..." />
      <nav ref={navRef}>{/* ... */}</nav>
    </>
  )
}

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:

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:

const { start } = useSpotlight()

<button onClick={() => start('onboarding', { stepIndex: 2 })}>
  Show me the billing tab
</button>

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:

import { useSpotlight } from 'react-tourlight'

function TourControls() {
  const { start, stop, next, previous, skip, isActive, currentStep, totalSteps } = useSpotlight()

  return (
    <div>
      {!isActive ? (
        <button onClick={() => start('onboarding')}>Start Tour</button>
      ) : (
        <>
          <p>Step {currentStep + 1} of {totalSteps}</p>
          <button onClick={previous}>Previous</button>
          <button onClick={next}>Next</button>
          <button onClick={skip}>Skip</button>
          <button onClick={stop}>Stop</button>
        </>
      )}
    </div>
  )
}

You can also jump to a specific step by index:

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:

// Provider-level — fires for any tour
<SpotlightProvider
  onComplete={(tourId) => {
    console.log(`Tour "${tourId}" completed`)
  }}
  onSkip={(tourId, stepIndex) => {
    console.log(`Tour "${tourId}" skipped at step ${stepIndex}`)
  }}
>
  {/* ... */}
</SpotlightProvider>

// Tour-level — fires for this specific tour
<SpotlightTour
  id="onboarding"
  steps={steps}
  onStart={() => 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:

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:

{
  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:

{
  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:

{
  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).