react-tourlight

Multi-Page Tours

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.
'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 (
    <SpotlightProvider persist navigate={(path) => router.push(path)}>
      <SpotlightTour
        id="onboarding"
        steps={[
          {
            target: '#dashboard-header',
            title: 'Dashboard',
            content: 'This is your home base.',
            route: '/dashboard',
          },
          {
            // Advancing here calls navigate('/settings'), then waits for the
            // target to appear on the destination page.
            target: '#profile-section',
            title: 'Profile',
            content: 'Update your details here.',
            route: '/settings',
          },
        ]}
      />
      {children}
    </SpotlightProvider>
  )
}

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:

PatternMatches
/settingsexactly /settings
/app/*/app and anything under it
/users/:id/users/42, /users/abc (segment counts must match)

Need something custom? Override it:

<SpotlightProvider
  isRouteActive={(route, pathname) => myMatcher(route, pathname)}
/>

Persistence & resume

persist saves tour state on every change and restores it on mount:

// localStorage (default)
<SpotlightProvider persist navigate={navigate} />

// custom storage (sessionStorage, memory, remote, ...)
import { createMemoryStorage } from 'react-tourlight'

<SpotlightProvider persist={createMemoryStorage()} navigate={navigate} />

Any object matching the SpotlightStorage shape works -- window.localStorage and window.sessionStorage qualify as-is:

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:

<SpotlightProvider persist resume={false} navigate={navigate} />

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.
<SpotlightProvider persist persistMaxAge={1000 * 60 * 60 * 24} /> // 24h

Options summary

PropDefaultDescription
persistundefined (off)true for localStorage, or a custom SpotlightStorage
persistKey'react-tourlight'Storage key
persistMaxAgeundefined (never)Discard snapshots older than N ms
resumetrueAuto-resume a persisted active tour on mount
navigateundefinedCalled with a step's route when it doesn't match
isRouteActivebuilt-in matcherCustom (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) with route steps to advance when the user clicks a real link:

const steps = [
  {
    target: '#go-to-settings',   // a real <a href="/settings"> 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:

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

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 <SpotlightTour id="onboarding" steps={steps} />
}

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 and React Router recipes for complete setups.