Skip to main content
userTourKit
Guides

Multi-tab Tours

Pause an active tour in tab B when the user starts the same flow in tab A — cross-tab coordination via BroadcastChannel.

domidex01Published Updated

A user with five tabs open is still one user. Without cross-tab coordination, they can trigger your onboarding tour in three of them at once and end up confused about which "step 2" they're looking at.

userTourKit ships a crossTab gate built on the native BroadcastChannel API — when you opt in, starting a tour in one tab pauses any active tour with the same id in every other tab.


Why this matters

Without crossTabWith crossTab
User opens app in 2 tabs → tour shows in bothTour shows in the tab that started it; other tab pauses
localStorage flow session resumes everywhere on refreshResume only happens where the tour is "owned"
onComplete may fire twice for the same user sessionSingle source of truth per browser

Most apps don't notice the problem until a user opens "the same dashboard, but logged in" in a new tab and gets the welcome tour again. crossTab.enabled: true is the fix.


Quick start

One-line opt-in
import { TourProvider } from '@tour-kit/core'

<TourProvider
  tours={[onboardingTour]}
  routePersistence={{
    enabled: true,
    flowSession: { storage: 'localStorage' },
    crossTab: { enabled: true },
  }}
  onTourPaused={(tourId, reason) => {
    console.log('paused', tourId, 'because', reason) // → 'cross-tab'
  }}
>
  {children}
</TourProvider>

That's all that's required. The provider:

  1. Generates a per-mount tabId (via crypto.randomUUID())
  2. Posts { type: 'tour:active', tourId, tabId } whenever a tour activates
  3. Subscribes to the channel and stops the local tour when another tab announces the same tour id

The onTourPaused callback is the integration point for analytics ("pause-on-cross-tab") or UX hooks ("resume in this tab" button).

crossTab is opt-in. Apps that don't pass it keep their current behavior. There is no default cross-tab gate.


When to enable

ScenariocrossTab.enabled?
Single-page app with sessionStorage flow sessionNo — sessionStorage is per-tab anyway
Multi-tab dashboard with localStorage flow sessionYes — prevents stale resume in tab B after tour was closed in tab A
Tour ends with a destructive action (delete, charge)Yes — avoid the user re-confirming the same action
Tour is purely informational (intro tooltip)Either; opt in if you want analytics consistency

The strong recommendation: always enable crossTab when flowSession.storage is 'localStorage'. The two combine to give "resume after refresh, but only here" — the predictable behavior most users expect.


API

routePersistence.crossTab accepts a CrossTabConfig:

interface CrossTabConfig {
  /** Master switch. */
  enabled: boolean
  /** BroadcastChannel name. Default: 'tourkit:active-flow'. */
  channel?: string
}

onTourPaused callback

onTourPaused?: (tourId: string, reason: 'cross-tab') => void

Fires after the local tour has been stopped (state.isActive is already false at this point). The reason discriminator is room for future pause sources (idle timeout, focus loss); today only 'cross-tab' is emitted.

Custom channel name

By default every tour shares the channel 'tourkit:active-flow'. If you have multiple isolated apps on the same origin and want them not to interfere, set a unique name:

<TourProvider
  routePersistence={{
    enabled: true,
    crossTab: { enabled: true, channel: 'admin-app:tour' },
  }}
>

Browser support

BroadcastChannel is available everywhere modern apps run:

BrowserMinimum version
Chrome / Edge54+ (2016)
Firefox38+ (2015)
Safari (macOS)15.4+ (2022)
Safari (iOS)15.4+ (2022)
Opera41+ (2016)

Older Safari (≤ 15.3) doesn't expose the API. The hook detects this and falls back to a no-op — your tour still runs, it just won't pause across tabs. No polyfill is required, and userTourKit deliberately doesn't bundle one (a BroadcastChannel polyfill weighs ~6KB gzipped).

If you need cross-tab support on legacy Safari, use a localStorage "storage" event polyfill in your app shell — userTourKit will pick it up automatically because the no-op only kicks in when typeof BroadcastChannel === 'undefined'.


Failure modes

The hook is built so the tour never crashes from a cross-tab failure:

  • BroadcastChannel undefinedpost and subscribe are no-ops; tour runs normally with no cross-tab coordination.
  • Channel name collision with non-tour-kit code → use the channel option above.
  • Self-broadcast — messages are filtered by tabId so you don't pause yourself.

Testing

The recommended pattern in Vitest + jsdom is to mount two <TourProvider> siblings in the same test process — they share the native BroadcastChannel automatically:

cross-tab.test.tsx
import { renderHook, act } from '@testing-library/react'

const tabA = renderHook(() => useTour(), { wrapper: makeWrapper(spyA) })
const tabB = renderHook(() => useTour(), { wrapper: makeWrapper(spyB) })

await act(async () => tabB.result.current.start('onboarding'))
await act(async () => tabA.result.current.start('onboarding'))
await act(async () => new Promise((r) => setTimeout(r, 0)))

expect(spyB).toHaveBeenCalledWith('onboarding', 'cross-tab')
expect(tabB.result.current.isActive).toBe(false)

For the no-channel fallback, stub BroadcastChannel to undefined in a single test only:

beforeEach(() => vi.stubGlobal('BroadcastChannel', undefined))
afterEach(() => vi.unstubAllGlobals())