react-tourlight

Headless Core

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

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 <button onClick={tour.start}>Start tour</button>
  }

  // Still resolving the target (lazy element, navigation, etc.)
  if (!tour.rect) return null

  return (
    <>
      {/* Your overlay — clipPath is generated for you */}
      <div
        style={{
          position: 'fixed',
          inset: 0,
          background: 'rgba(0,0,0,0.5)',
          clipPath: tour.clipPath,
          pointerEvents: 'none',
        }}
      />

      {/* Your tooltip, positioned from the measured rect */}
      <div
        style={{
          position: 'fixed',
          top: tour.rect.y + tour.rect.height + 8,
          left: tour.rect.x,
          background: 'white',
          padding: 16,
          borderRadius: 8,
        }}
      >
        <h3>{tour.step?.title}</h3>
        <div>{tour.step?.content}</div>
        <div>
          <button onClick={tour.previous}>Back</button>
          <button onClick={tour.next}>
            {tour.currentIndex + 1 === tour.totalSteps ? 'Done' : 'Next'}
          </button>
          <button onClick={tour.skip}>Skip</button>
        </div>
      </div>
    </>
  )
}

Options

interface UseTourOptions {
  steps: SpotlightStep[]
  onComplete?: () => void
  onSkip?: (stepIndex: number) => void
  onStateChange?: (state: TourState) => void
  initialState?: Partial<TourState>   // 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

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:

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:

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

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)
}