@brainfish-ai/wayfinder-core
v0.1.10
Published
A framework-agnostic product tour library with spotlight overlay and analytics hooks
Readme
@wayfinder/core
A framework-agnostic, TypeScript-first product tour library. Spotlight your UI elements, guide users step by step, and hook into every interaction for analytics — with no framework dependencies.
Features
- Spotlight overlay — smooth CSS-transition cutout that glides between elements
- Smart tooltip positioning — powered by Floating UI; respects screen edges, scrolling, and all 12 placements
- Analytics event hooks — typed callbacks at every user interaction (
onStart,onNext,onPrev,onSkip,onComplete,onError) - Keyboard navigation —
→/Enteradvance,←goes back,Escapeskips - Themeable — override any CSS class via the
themeconfig; no Tailwind required in your app - Single active tour guard — only one tour can run at a time;
Wayfinder.isRunning()lets you gate programmatically - Obfuscated class support —
StepTargetaccepts CSS selectors, direct refs, or a lazy resolver function;tourId()helper +data-tourconvention;waitForTargetMutationObserver retry for SPA async DOM - Zero framework dependency — plain DOM manipulation; works with React, Vue, Angular, Svelte, or vanilla JS
- Tiny — ~14 kB ESM, ~12 kB CJS (gzipped: ~4 kB)
Installation
npm install @wayfinder/core @floating-ui/dom
# or
yarn add @wayfinder/core @floating-ui/dom@floating-ui/dom is a peer dependency and must be installed alongside the library.
Quick start
import { Wayfinder } from '@wayfinder/core';
const tour = new Wayfinder({
steps: [
{
id: 'welcome',
target: '#dashboard-header',
title: 'Welcome to your Dashboard',
description: 'Here you can see a high-level overview of your stats.',
placement: 'bottom',
},
{
id: 'create-button',
target: '#btn-create-new',
title: 'Create a new project',
description: 'Click here to get started.',
placement: 'left',
},
],
});
document.getElementById('start-tour')!.addEventListener('click', () => {
tour.start();
});Wayfinder injects its own styles at runtime — no CSS import required.
Configuration
new Wayfinder(config: WayfinderConfig)WayfinderConfig
| Property | Type | Required | Description |
|---|---|---|---|
| steps | Step[] | One of steps or flows | Ordered steps for a single-flow tour. Acts as a fallback when flows is also provided and no flow matches the current profile. |
| flows | Flow[] | One of steps or flows | Multiple named flows, each with a profile matcher and its own step set. Evaluated in order at start() time; first match wins. |
| profile | UserProfile | No | Arbitrary key-value object passed to each Flow.match() function and to per-step condition / next callbacks. Defaults to {}. |
| theme | Theme | No | CSS class overrides and visual options. |
| events | EventHandlers | No | Analytics / lifecycle callbacks. |
| waitForTargetTimeoutMs | number | No | Max time (ms) to wait for a target element to appear in the DOM (uses a MutationObserver). Fires onError on timeout. Default: 3000. |
| tourAttribute | string | No | The HTML attribute used by tourId() and the framework helpers. Change if data-tour conflicts with another library. Default: 'data-tour'. |
| launcher | LauncherConfig | No | Optional self-serve launcher beacon. A pulsing ring is attached to the target element; clicking it starts the tour automatically. |
Flow
A named tour variant tied to a user profile. Flows let you show a different set of steps depending on user state — role, plan, onboarding status, etc.
| Property | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Unique identifier — surfaced in all analytics payloads as flowId. |
| match | (profile: UserProfile) => boolean | Yes | Called with the current profile at start() time. Return true to select this flow. Flows are tested in array order; first truthy match wins. |
| steps | Step[] | Yes | Steps shown when this flow is matched. |
Example — role-based flows:
const tour = new Wayfinder({
profile: { role: 'admin', plan: 'enterprise' },
flows: [
{
id: 'admin-onboarding',
match: (profile) => profile.role === 'admin',
steps: [
{ id: 'users', target: '#users-nav', title: 'User management', description: 'Manage your team here.' },
{ id: 'billing', target: '#billing-nav', title: 'Billing', description: 'View and update your subscription.' },
],
},
{
id: 'member-onboarding',
match: () => true, // fallback flow
steps: [
{ id: 'dashboard', target: '#dashboard', title: 'Your Dashboard', description: 'Everything starts here.' },
],
},
],
});Step
| Property | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Unique identifier (used in analytics payloads). |
| target | StepTarget | Yes | See StepTarget below. |
| title | string | Yes | Tooltip heading. |
| description | string | Yes | Tooltip body text. |
| placement | Placement | No | Floating UI placement (default: 'bottom'). Any of: top, bottom, left, right, and their -start / -end variants. |
| spotlightPadding | number | No | Extra padding (px) around the spotlight cutout for this step. |
| condition | (profile: UserProfile) => boolean | No | Guard evaluated just before the step is displayed. Return false to skip the step and auto-advance in the current direction. |
| next | string \| ((profile: UserProfile) => string \| null \| undefined) | No | ID of the step to show after this one, overriding default linear progression. Return null / undefined from the function to complete the tour early. |
| clickOnNext | boolean | No | When true, pressing Next programmatically clicks the step's target element before advancing. Useful for demo flows. Default: false. |
| url | string \| (() => string) | No | URL or path this step belongs to. Wayfinder navigates to it if the current URL doesn't match. Supports :param and * wildcards for pattern matching without triggering navigation. |
condition example — admin-only step:
{
id: 'invite-team',
target: '[data-tour="invite-btn"]',
title: 'Invite your team',
description: 'Add teammates with a single click.',
condition: (profile) => profile.role === 'admin',
}next example — dynamic branching:
{
id: 'check-integration',
target: '[data-tour="integrations"]',
title: 'Integrations',
description: 'Connect your tools.',
next: (profile) => profile.hasIntegration ? 'step-done' : 'step-connect',
}clickOnNext example — demo walkthrough:
{
id: 'open-menu',
target: '[data-tour="menu-btn"]',
title: 'Open the menu',
description: 'Click here to open the navigation menu.',
clickOnNext: true, // clicks the element then advances
}url example — cross-page tour:
[
{ id: 'settings', target: '[data-tour="settings-tab"]', url: '/settings', title: 'Settings', description: '...' },
{ id: 'profile', target: '[data-tour="profile-form"]', url: '/settings/profile', title: 'Profile', description: '...' },
]StepTarget
type StepTarget =
| string // CSS selector: '#my-btn', '[data-tour="create"]'
| HTMLElement // Direct element reference
| (() => HTMLElement | null); // Lazy resolver — called at step-show timeThe resolver function form is the escape hatch for obfuscated class names. It is called freshly at every step transition, so it works with CSS Modules, styled-components, React refs, and any other dynamic element source:
// CSS Modules — resolve by data-testid instead of the hashed class
{ target: () => document.querySelector('[data-testid="create-button"]') }
// React ref captured in outer scope
{ target: () => myRef.current }
// styled-components — target by a stable attribute you control
{ target: () => document.querySelector('[data-tour="create-btn"]') }Theme
All class properties replace the default class string when provided. Use this to supply your own Tailwind or custom CSS classes.
| Property | Type | Description |
|---|---|---|
| tooltip | string | Tooltip card wrapper |
| tooltipTitle | string | Title element |
| tooltipDescription | string | Description element |
| buttonNext | string | Next / Finish button |
| buttonPrev | string | Back button |
| buttonSkip | string | Skip button |
| progress | string | "X of Y" progress indicator |
| overlayColor | string | CSS color for the overlay background (default: rgba(0,0,0,0.55)) |
| spotlightBorderRadius | number | Border radius (px) of the spotlight cutout (default: 4) |
Example — Tailwind overrides:
const tour = new Wayfinder({
theme: {
tooltip: 'bg-white rounded-xl shadow-lg p-4 max-w-sm',
buttonNext: 'bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700',
buttonSkip: 'text-gray-500 hover:text-gray-700 text-sm',
},
steps: [...],
});LauncherConfig
An optional self-serve beacon. A pulsing ring is rendered over the target element; clicking the element (or the ring) starts the tour and automatically removes the beacon.
| Property | Type | Required | Description |
|---|---|---|---|
| target | StepTarget | Yes | Element to attach the beacon to (CSS selector, HTMLElement, or resolver function). |
| color | string | No | Color of the pulse ring and center dot. Default: '#4f46e5'. |
| size | number | No | Diameter (px) of the center dot. Default: 14. |
Example — "Take a tour" help button:
const tour = new Wayfinder({
launcher: {
target: '[data-tour="help-btn"]',
color: '#4f46e5',
size: 16,
},
steps: [...],
});
// No need to call tour.start() — the beacon handles it.EventHandlers
Hook into every user interaction to push data to your analytics tool. Every payload includes a flowId field — the ID of the matched Flow, or null when top-level steps are used.
| Event | Payload type | Fired when |
|---|---|---|
| onStart | { totalSteps: number, flowId: string \| null } | Tour begins |
| onNext | { step: Step, index: number, flowId: string \| null } | User advances a step |
| onPrev | { step: Step, index: number, flowId: string \| null } | User goes back a step |
| onSkip | { step: Step, index: number, flowId: string \| null } | User dismisses the tour early |
| onComplete | { steps: Step[], flowId: string \| null } | User finishes the last step |
| onError | { step: Step \| null, error: string } | Target element not found in DOM |
Example — Mixpanel integration:
const tour = new Wayfinder({
steps: [...],
events: {
onStart: ({ totalSteps }) => {
mixpanel.track('Onboarding Started', { totalSteps });
},
onNext: ({ step }) => {
mixpanel.track('Onboarding Step Viewed', { stepId: step.id });
},
onSkip: ({ step, index }) => {
mixpanel.track('Onboarding Skipped', { dropoffStep: step.id, dropoffIndex: index });
},
onComplete: () => {
mixpanel.track('Onboarding Completed');
},
},
});Instance API
| Method | Description |
|---|---|
| start() | Start from step 0. No-ops if a tour is already running. |
| next() | Advance to the next step. Fires onComplete on the last step. |
| prev() | Go back one step. No-op on the first step. |
| skip() | Dismiss the tour at the current step. Fires onSkip. |
| goTo(index) | Jump to any step by zero-based index. |
| destroy() | Tear down all DOM nodes and clear state. |
Static API
| Method | Returns | Description |
|---|---|---|
| Wayfinder.isRunning() | boolean | Returns true if any tour instance is currently active. Only one tour may run at a time. |
Example — guard against concurrent tours:
document.getElementById('start-tour')!.addEventListener('click', () => {
if (Wayfinder.isRunning()) {
console.warn('A tour is already in progress.');
return;
}
tour.start();
});Obfuscated class names
When your app uses CSS Modules, styled-components, Emotion, or a build minifier, class-based selectors become unstable across builds. Wayfinder provides three complementary strategies — pick the one that fits your stack.
Strategy 1 — tourId() helper (recommended, any framework)
Add a stable data-tour attribute to the element you want to target. Data attributes are never touched by any build tool.
import { Wayfinder, tourId } from '@wayfinder/core';<!-- Mark the element once in your template/JSX -->
<button data-tour="create-btn">New Project</button>// Reference it in your step config
{ id: 'create-btn', target: tourId('create-btn') }
// tourId('create-btn') returns '[data-tour="create-btn"]'Use a custom attribute name if data-tour is taken:
new Wayfinder({
tourAttribute: 'data-wf', // changes the attribute tourId() produces
steps: [{ target: tourId('create-btn', 'data-wf') }]
})Strategy 2 — Resolver function target
Pass a lazy function as target. It is called fresh at each step transition:
{ target: () => document.querySelector('[data-testid="my-btn"]') }
{ target: () => myReactRef.current }Strategy 3 — waitForTargetTimeoutMs (SPA async DOM)
Wayfinder always uses a MutationObserver to wait for target elements to appear in the DOM before showing each step — no extra flag required. This covers SPA route changes, modal opens, and panel reveals automatically.
Tune the timeout window with waitForTargetTimeoutMs (default 3000 ms). If the element doesn't appear in time, onError fires:
new Wayfinder({
waitForTargetTimeoutMs: 5000,
steps: [...],
})Framework packages
| Package | How it helps |
|---|---|
| @wayfinder/react | useTourTarget hook + TourTarget component — writes data-tour via a React ref |
| @wayfinder/vue | v-tour-target directive + WayfinderPlugin |
| @wayfinder/vite-plugin | Injects data-tour at build time — zero markup changes needed |
Keyboard navigation
When a tour is active the following keyboard shortcuts are available globally:
| Key | Action |
|---|---|
| → or Enter | Next step |
| ← | Previous step |
| Escape | Skip tour |
CSS customisation
Wayfinder injects a minimal stylesheet with the following class hooks. You can target these directly in your own CSS:
| Class | Element |
|---|---|
| .wf-overlay | Full-screen click-blocking overlay |
| .wf-spotlight | The cutout div (box-shadow creates the dark surround) |
| .wf-tooltip | Tooltip card wrapper |
| .wf-tooltip-title | Title text |
| .wf-tooltip-description | Description text |
| .wf-tooltip-footer | Footer row (progress + controls) |
| .wf-progress | "X of Y" text |
| .wf-controls | Button container |
| .wf-btn-next | Next / Finish button |
| .wf-btn-prev | Back button |
| .wf-btn-skip | Skip button |
Output formats
The package ships three build targets:
| File | Format | Use case |
|---|---|---|
| dist/wayfinder.js | ESM | Bundlers (Vite, Webpack, Rollup) |
| dist/wayfinder.cjs | CommonJS | Node / legacy bundlers |
| dist/wayfinder.umd.cjs | UMD | CDN / <script> tag |
| dist/index.d.ts | TypeScript declarations | Type checking |
CDN usage:
<script src="https://unpkg.com/@floating-ui/dom"></script>
<script src="https://unpkg.com/@wayfinder/core/dist/wayfinder.umd.cjs"></script>
<script>
const { Wayfinder } = Wayfinder; // UMD global
const tour = new Wayfinder({ steps: [...] });
</script>Development
# From the repo root
yarn install --no-immutable
# Build the library (outputs to packages/core/dist/)
yarn build
# Watch mode during development
yarn workspace @wayfinder/core run dev
# Run the interactive demo at http://localhost:3111
yarn devLicense
MIT
