react-tourlight

Migrating from React Joyride

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 Joyridereact-tourlightNotes
<Joyride steps={...} /><SpotlightTour id="..." steps={...} />Tours require a unique id
run propuseSpotlight().start(id)Imperative control via hook
continuous propAlways continuousStep-by-step is the default
callback with STATUSonComplete / onSkip propsCleaner event model
styles proptheme prop on ProviderCentralized theming
floaterPropsFloating UI peer depUses @floating-ui/react-dom
disableOverlayoverlayColor="transparent"Set overlay to transparent
spotlightPaddingspotlightPadding on stepSame concept, per-step
localelabels prop on Provideri18n for button text
tooltipComponentrenderTooltip render propFull control via render props

Step-by-step migration

1. Install react-tourlight

npm uninstall react-joyride
npm install react-tourlight @floating-ui/react-dom

2. Replace the provider

Before (Joyride):

import Joyride from 'react-joyride'

function App() {
  const [run, setRun] = useState(false)
  return (
    <>
      <Joyride
        steps={steps}
        run={run}
        continuous
        callback={(data) => {
          if (data.status === 'finished') handleComplete()
        }}
      />
      <button onClick={() => setRun(true)}>Start Tour</button>
    </>
  )
}

After (react-tourlight):

import { SpotlightProvider, SpotlightTour, useSpotlight } from 'react-tourlight'
import 'react-tourlight/styles.css'

function App() {
  return (
    <SpotlightProvider>
      <SpotlightTour
        id="onboarding"
        steps={steps}
        onComplete={() => handleComplete()}
      />
      <StartButton />
    </SpotlightProvider>
  )
}

function StartButton() {
  const { start } = useSpotlight()
  return <button onClick={() => start('onboarding')}>Start Tour</button>
}

3. Convert step format

Before:

const steps = [
  {
    target: '.my-element',
    content: 'This is the first step',
    title: 'Step 1',
    placement: 'bottom',
    disableBeacon: true,
  },
]

After:

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 <SpotlightBeacon target="..." tour="..." /> 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:

callback={(data) => {
  const { status, type, index } = data
  if (status === 'finished') { /* done */ }
  if (status === 'skipped') { /* skipped */ }
  if (type === 'step:after') { /* step changed */ }
}}

After:

<SpotlightTour
  id="onboarding"
  steps={steps}
  onStart={() => { /* started */ }}
  onStepChange={(stepIndex, step) => { /* step changed */ }}
  onComplete={() => { /* done */ }}
  onSkip={(stepIndex) => { /* skipped at step */ }}
/>

5. Update custom tooltips

Before:

tooltipComponent={({ step, primaryProps, backProps, skipProps, index, size }) => (
  <div>
    <h3>{step.title}</h3>
    <p>{step.content}</p>
    <button {...backProps}>Back</button>
    <button {...primaryProps}>Next</button>
  </div>
)}

After:

renderTooltip={({ step, next, previous, skip, close, isFirst, isLast }) => (
  <div>
    <button onClick={close} aria-label="Close">×</button>
    <h3>{step.title}</h3>
    <p>{step.content}</p>
    <button onClick={previous} disabled={isFirst}>Back</button>
    <button onClick={next}>{isLast ? 'Done' : 'Next'}</button>
  </div>
)}

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 <SpotlightBeacon> is an explicit, opt-in hotspot you place where you want one:

import { SpotlightBeacon } from 'react-tourlight'

<SpotlightBeacon target="#export-button" tour="onboarding" stepIndex={2} />

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