Flowsterix Best Practices
Flowsterix is a state machine-based guided tour library for React applications. Flows are declarative step sequences with automatic progression rules, lifecycle hooks, and persistence.
Quick Start
Installation
Core packages
npm install @flowsterix/core @flowsterix/react motion
Recommended: Add preconfigured shadcn components
npx shadcn@latest add https://flowsterix.com/r/tour-hud.json
Prefer the shadcn components - they provide polished, accessible UI out of the box and follow the design patterns shown in the examples.
Minimal Example
import { createFlow, type FlowDefinition } from '@flowsterix/core' import { TourProvider, TourHUD } from '@flowsterix/react' import type { ReactNode } from 'react'
const onboardingFlow: FlowDefinition<ReactNode> = createFlow({ id: 'onboarding', version: { major: 1, minor: 0 }, autoStart: true, steps: [ { id: 'welcome', target: 'screen', advance: [{ type: 'manual' }], content: <p>Welcome to our app!</p>, }, { id: 'feature', target: { selector: '[data-tour-target="main-feature"]' }, advance: [{ type: 'event', event: 'click', on: 'target' }], content: <p>Click this button to continue</p>, }, ], })
export function App({ children }) { return ( <TourProvider flows={[onboardingFlow]} storageNamespace="my-app"> <TourHUD overlay={{ showRing: true }} /> {children} </TourProvider> ) }
Core Concepts
FlowDefinition
createFlow({ id: string, // Unique identifier version: { major: number, minor: number }, // For storage migrations steps: Step[], // Array of tour steps dialogs?: Record<string, DialogConfig>, // Dialog configurations (see Radix Dialog Integration) autoStart?: boolean, // Start on mount (default: false) resumeStrategy?: 'chain' | 'current', // How to run onResume hooks hud?: FlowHudOptions, // UI configuration migrate?: (ctx) => FlowState | null, // Version migration handler })
Step Anatomy
{ id: string, // Unique within flow target: StepTarget, // What to highlight content: ReactNode, // Popover content dialogId?: string, // Reference to flow.dialogs entry (auto-opens dialog) advance?: AdvanceRule[], // When to move to next step placement?: StepPlacement, // Popover position route?: string | RegExp, // Only show on matching routes waitFor?: StepWaitFor, // Block until condition met targetBehavior?: StepTargetBehavior, // Scroll/visibility handling onEnter?: (ctx) => void, // Fires when step activates onResume?: (ctx) => void, // Fires when resuming from storage onExit?: (ctx) => void, // Fires when leaving step controls?: { back?, next? }, // Button visibility }
Step Targets
// Full-screen overlay (no element highlight) target: 'screen'
// CSS selector (recommended: use data attributes) target: { selector: '[data-tour-target="feature"]' }
// Dynamic node resolution target: { getNode: () => document.querySelector('.dynamic-el') }
Always use data-tour-target attributes instead of CSS classes for stability.
Advance Rules
Rules define when a step automatically progresses. First matching rule wins.
Type Usage Example
manual
Next button only { type: 'manual' }
event
DOM event on target { type: 'event', event: 'click', on: 'target' }
delay
Timer-based { type: 'delay', ms: 3000 }
route
URL change { type: 'route', to: '/dashboard' }
predicate
Polling condition { type: 'predicate', check: (ctx) => isReady() }
// Combine rules for flexibility advance: [ { type: 'event', event: 'click', on: 'target' }, { type: 'delay', ms: 10000 }, // Fallback after 10s ]
React Integration
TourProvider Props
<TourProvider flows={[flow1, flow2]} // Flow definitions storageNamespace="my-app" // localStorage key prefix persistOnChange={true} // Auto-save state changes backdropInteraction="block" // 'block' | 'passthrough' lockBodyScroll={false} // Prevent page scroll labels={{ // Customize UI text for internationalization back: 'Zurück', next: 'Weiter', finish: 'Fertig', skip: 'Tour überspringen', // See Internationalization section for full list }} analytics={{ // Event handlers onFlowStart: (p) => track('tour_start', p), onStepEnter: (p) => track('step_view', p), }} />
useTour Hook
const { activeFlowId, // Currently active flow ID or null state, // FlowState: status, stepIndex, version activeStep, // Current Step object startFlow, // (flowId, options?) => start a flow next, // () => advance to next step back, // () => go to previous step pause, // () => pause the flow cancel, // (reason?) => cancel the flow complete, // () => mark flow complete advanceStep, // (stepId) => FlowState | null — advance only if on that step } = useTour()
Conditional Advance with advanceStep
Use advanceStep(stepId) when you want to advance the tour only if the user is currently on a specific step. This is useful for components that trigger tour progression as a side effect.
const { advanceStep } = useTour()
// In a logo upload component: const handleLogoUpload = async (file: File) => { await uploadLogo(file) advanceStep('change-logo') // Only advances if tour is on 'change-logo' step }
Behavior:
-
If currently on the specified step → advances to next (or completes if last step)
-
If on a different step → silent no-op (returns current state)
-
If stepId doesn't exist → silent no-op (not an error)
-
If no active flow → returns null (safe to call without checking flow state)
TourHUD Configuration
<TourHUD overlay={{ padding: 12, // Padding around highlight radius: 12, // Border radius of cutout showRing: true, // Glow effect around target blurAmount: 6, // Controls unified faux-glow intensity }} popover={{ maxWidth: 360, offset: 32, // Distance from target }} controls={{ showSkip: true, skipMode: 'hold', // 'click' | 'hold' (hold-to-confirm) }} progress={{ show: true, variant: 'dots', // 'dots' | 'bar' | 'fraction' }} mobile={{ enabled: true, // Enable mobile drawer (default: true) breakpoint: 640, // Width threshold for mobile (default: 640) defaultSnapPoint: 'expanded', // Initial state (default: 'expanded') snapPoints: ['minimized', 'expanded'], // Available states allowMinimize: true, // Allow swipe down to minimize }} />
Overlay rendering model: Flowsterix now uses a unified SVG-based overlay path across desktop and mobile. Avoid browser-specific mask toggles and optimize visuals through padding , radius , showRing , and blurAmount .
Mobile Drawer
On viewports ≤640px, TourHUD automatically renders a bottom sheet drawer instead of a floating popover. Users can swipe to minimize (see highlighted target) or expand (read content).
Snap Points:
-
minimized (~100px) - Shows step indicator + nav buttons only
-
peek (~40% of expanded) - Optional middle state for summaries
-
expanded (auto) - Sized to content, capped at maxHeightRatio
Gestures:
-
Swipe down → minimize
-
Swipe up → expand
-
Tap handle → toggle between states
Behavior:
-
Auto-sizes to content - Drawer height matches content + chrome (handle, header, controls)
-
Capped at max - Won't exceed maxHeightRatio of viewport (default 85%)
-
No flicker - Starts small, animates up once content is measured
-
Resets to expanded on step transitions
-
Content crossfades between steps
-
Safe area insets for notched phones
-
aria-live announcement when minimized
Constrained Scroll Lock: When body scroll lock is enabled and the highlighted target exceeds viewport height, constrained scroll lock allows scrolling within target bounds only:
-
Target fits in viewport → normal scroll lock (overflow: hidden )
-
Target exceeds viewport → scroll constrained to target bounds (user can see entire element)
// Auto-size with custom max height <TourHUD mobile={{ maxHeightRatio: 0.7, // Cap at 70% viewport }} />
// Enable three-state drawer with peek <TourHUD mobile={{ snapPoints: ['minimized', 'peek', 'expanded'], defaultSnapPoint: 'expanded', }} />
Common Mistakes
Missing data-tour-target attributes - Tour cannot find elements
// Bad: fragile to styling changes target: { selector: '.btn-primary' }
// Good: semantic and stable target: { selector: '[data-tour-target="submit-btn"]' }
No waitFor for async content - Step shows before content ready
// Add waitFor when targeting dynamically loaded elements waitFor: { selector: '[data-tour-target="api-result"]', timeout: 8000 }
Ignoring sticky headers - Target scrolls behind fixed navigation
targetBehavior: { scrollMargin: { top: 80 }, // Height of sticky header scrollMode: 'start', scrollDurationMs: 350, // Keep scroll timing aligned with HUD motion }
Wrong version format - Use object, not number
// Bad version: 1
// Good version: { major: 1, minor: 0 }
Forgetting onResume hooks - UI state not restored after reload
// Bad: UI broken after page reload onEnter: () => ensureMenuOpen(),
// Good: Both hooks restore UI state onEnter: () => ensureMenuOpen(), onResume: () => ensureMenuOpen(), onExit: () => ensureMenuClosed(),
Scroll Synchronization for Long Jumps
When consecutive steps are far apart on the page, set a fixed scrollDurationMs on the step:
{ id: 'architecture', target: { selector: '[data-tour-target="architecture"]' }, targetBehavior: { scrollMode: 'center', scrollDurationMs: 350, }, }
Guidelines:
-
Use 250-450ms for most landing pages. Start with 350ms .
-
Keep page-level CSS smooth scroll if you want; when scrollDurationMs is set, Flowsterix temporarily bypasses global CSS smooth scrolling so timing stays deterministic.
-
During long jumps, overlay highlight and popover stay anchored to the previous on-screen position until the next target enters the viewport, then transition to the new target.
-
Use scrollMode: 'preserve' for minimal movement, center for guided storytelling, or start when sticky headers need strict top alignment.
Shadcn Components
The shadcn registry provides preconfigured, polished components. Always prefer these over custom implementations.
Important: The tour components require shadcn CSS variables (--popover , --border , --destructive , etc.). If you're not using shadcn/ui, see CSS Setup for the required variables.
Available Components
Component Install Command Usage
tour-hud
npx shadcn@latest add https://flowsterix.com/r/tour-hud.json
Full HUD with overlay & popover
step-content
npx shadcn@latest add https://flowsterix.com/r/step-content.json
Step layout primitives
mobile-drawer
npx shadcn@latest add https://flowsterix.com/r/mobile-drawer.json
Bottom sheet for mobile
mobile-drawer-handle
npx shadcn@latest add https://flowsterix.com/r/mobile-drawer-handle.json
Swipe handle for drawer
Step Content Primitives
Use these components for consistent step styling:
import { StepContent, StepTitle, StepText, StepHint, } from '@/components/step-content'
content: ( <StepContent> <StepTitle>Feature Discovery</StepTitle> <StepText> This is the main explanation text with muted styling. </StepText> <StepHint>Click the button to continue.</StepHint> </StepContent> )
-
StepContent
-
Grid container with proper spacing
-
StepTitle
-
Semibold heading (supports size="lg" for welcome screens)
-
StepText
-
Muted paragraph text
-
StepHint
-
Italic hint text for user instructions
Radix Dialog Integration
Use useRadixTourDialog for declarative dialog control during tours.
Setup
import { createFlow } from '@flowsterix/core' import { useRadixTourDialog } from '@flowsterix/react' import * as Dialog from '@radix-ui/react-dialog'
// 1. Configure dialogs in flow definition const flow = createFlow({ id: 'onboarding', version: { major: 1, minor: 0 }, dialogs: { settings: { autoOpen: true, // Open when entering dialog steps autoClose: 'differentDialog', // Close when moving to non-dialog step onDismissGoToStepId: 'settings-trigger', // Where to go if user closes dialog }, }, steps: [ { id: 'settings-trigger', target: '#settings-btn', content: 'Click here' }, { id: 'settings-tab1', dialogId: 'settings', target: '#tab1', content: 'First tab' }, { id: 'settings-tab2', dialogId: 'settings', target: '#tab2', content: 'Second tab' }, // Dialog stays open for consecutive steps with same dialogId { id: 'done', target: 'screen', content: 'All done' }, // Dialog auto-closes when entering 'done' (no dialogId) ], })
// 2. Use hook in your dialog component function SettingsDialog({ children }) { const { dialogProps, contentProps } = useRadixTourDialog({ dialogId: 'settings' })
return ( <Dialog.Root {...dialogProps}> <Dialog.Trigger data-tour-target="settings-trigger">Settings</Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content {...contentProps} data-tour-target="settings-dialog"> {children} </Dialog.Content> </Dialog.Portal> </Dialog.Root> ) }
Dialog Configuration Options
dialogs: { myDialog: { // Auto-open behavior (default: true for both) autoOpen: { onEnter: true, // Open when entering a step with this dialogId onResume: true, // Open when resuming to a step with this dialogId }, // Or disable all auto-open: autoOpen: false,
// Auto-close behavior (default: 'differentDialog')
autoClose: 'differentDialog', // Close when next step has different/no dialogId
autoClose: 'always', // Always close on step exit
autoClose: 'never', // Manual close only
// Required: where to navigate when user dismisses dialog
onDismissGoToStepId: 'some-step-id',
}, }
Focus Management: useRadixDialogAdapter
For dialogs without tour integration that still need focus handling during tours:
import { useRadixDialogAdapter } from '@flowsterix/react'
function SimpleDialog({ children }) { const { dialogProps, contentProps } = useRadixDialogAdapter({ disableEscapeClose: true, })
return ( <Dialog.Root {...dialogProps}> <Dialog.Content {...contentProps}>{children}</Dialog.Content> </Dialog.Root> ) }
Lifecycle Hooks
Lifecycle hooks synchronize UI state with tour progression. Use them when steps target elements inside collapsible panels, modals, drawers, or other dynamic UI.
When to Use Each Hook
Hook Fires When Purpose
onEnter
Step activates (fresh start) Open UI, prepare state
onResume
Step restores from storage Restore UI after page reload
onExit
Leaving step (next/back/skip) Clean up, close UI
Common Patterns
- Opening/Closing Drawers & Menus
// Helper functions to toggle menu state const ensureMenuOpen = () => { const panel = document.querySelector('[data-tour-target="menu-panel"]') if (!(panel instanceof HTMLElement)) return const isClosed = panel.classList.contains('-translate-x-full') if (isClosed) { document.querySelector('[data-tour-target="menu-button"]')?.click() } }
const ensureMenuClosed = () => { const panel = document.querySelector('[data-tour-target="menu-panel"]') if (!(panel instanceof HTMLElement)) return const isClosed = panel.classList.contains('-translate-x-full') if (!isClosed) { panel.querySelector('[aria-label="Close menu"]')?.click() } }
- Step Targeting Element Inside Drawer
{ id: 'menu-link', target: { selector: '[data-tour-target="api-link"]' }, onEnter: () => ensureMenuOpen(), // Open drawer on fresh entry onResume: () => ensureMenuOpen(), // Open drawer on page reload onExit: () => ensureMenuClosed(), // Close drawer when leaving advance: [{ type: 'route', to: '/api-demo' }], content: ( <StepContent> <StepTitle>API Demo</StepTitle> <StepText>Click to explore the API features.</StepText> </StepContent> ), }
- Expanding Nested Accordions
const ensureAccordionExpanded = () => { ensureMenuOpen() // Parent must be open first const submenu = document.querySelector('[data-tour-target="submenu"]') if (submenu) return // Already expanded document.querySelector('[data-tour-target="accordion-toggle"]')?.click() }
{ id: 'submenu-item', target: { selector: '[data-tour-target="submenu"]' }, onResume: () => ensureAccordionExpanded(), content: ... }
- Closing UI When Moving Away
{ id: 'feature-grid', target: { selector: '#feature-grid' }, onEnter: () => { setTimeout(() => ensureMenuClosed(), 0) // Allow menu click to register first }, onResume: () => ensureMenuClosed(), content: ... }
Critical Rules
-
Always implement onResume when you have onEnter
-
Users may reload the page mid-tour
-
Check state before acting - Don't toggle already-open menus
-
Use setTimeout for sequential actions - Give previous clicks time to register
-
Keep hooks idempotent - Safe to call multiple times
Step Placements
'auto' | 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' | 'auto-start' | 'auto-end'
Route Gating
Steps can be constrained to specific routes using the route property. The flow automatically pauses when the user navigates away and resumes when they return.
Route Mismatch Behavior
{ id: 'dashboard-feature', target: { selector: '[data-tour-target="widget"]' }, route: '/dashboard', // Step only active on /dashboard content: <p>This widget shows your stats</p>, }
Behavior when user navigates away from /dashboard :
-
Flow pauses immediately (overlay disappears)
-
User can browse other pages freely
-
When user returns to /dashboard , flow auto-resumes
Missing Target Behavior (No Route Defined)
When a step has no route property and the target element is missing:
-
Grace period (400ms) - Allows async elements to mount
-
If still missing → Flow pauses
-
When user navigates to a different page → Flow resumes and re-checks
-
If target found → Flow continues
-
If still missing → Grace period → Pause again
This prevents showing broken UI when users accidentally navigate away.
Route Patterns
// Exact match route: '/dashboard'
// Regex pattern route: /^/users/\d+$/
// With path parameters (use regex) route: /^/products/[^/]+$/
Internationalization (i18n)
All user-facing text can be customized via the labels prop on TourProvider .
Available Labels
<TourProvider flows={[...]} labels={{ // Button labels back: 'Back', next: 'Next', finish: 'Finish', skip: 'Skip tour', holdToConfirm: 'Hold to confirm',
// Aria labels for screen readers
ariaStepProgress: ({ current, total }) => `Step ${current} of ${total}`,
ariaTimeRemaining: ({ ms }) => `${Math.ceil(ms / 1000)} seconds remaining`,
ariaDelayProgress: 'Auto-advance progress',
// Visible formatters
formatTimeRemaining: ({ ms }) => `${Math.ceil(ms / 1000)}s remaining`,
// Target issue messages (shown when target element is problematic)
targetIssue: {
missingTitle: 'Target not visible',
missingBody: 'The target element is not currently visible...',
missingHint: 'Showing the last known position until the element returns.',
hiddenTitle: 'Target not visible',
hiddenBody: 'The target element is not currently visible...',
hiddenHint: 'Showing the last known position until the element returns.',
detachedTitle: 'Target left the page',
detachedBody: 'Navigate back to the screen that contains this element...',
},
}}
German Example
const germanLabels = {
back: 'Zurück',
next: 'Weiter',
finish: 'Fertig',
skip: 'Tour überspringen',
holdToConfirm: 'Gedrückt halten zum Bestätigen',
ariaStepProgress: ({ current, total }) => Schritt ${current} von ${total},
targetIssue: {
missingTitle: 'Ziel nicht sichtbar',
missingBody: 'Das Zielelement ist derzeit nicht sichtbar.',
detachedTitle: 'Ziel hat die Seite verlassen',
detachedBody: 'Navigieren Sie zurück zur Seite mit diesem Element.',
// ... other labels
},
}
<TourProvider flows={[...]} labels={germanLabels}>
DevTools
The @flowsterix/react/devtools subpath provides development tools for building and debugging tours:
-
Steps tab - Visual element picker to capture tour steps and export JSON for AI
-
Flows tab - View and edit stored flow states for debugging
Setup
import { DevToolsProvider } from '@flowsterix/react/devtools'
function App() { return ( <TourProvider flows={[...]}> <DevToolsProvider enabled={process.env.NODE_ENV === 'development'}> <YourApp /> </DevToolsProvider> </TourProvider> ) }
Steps Tab (Element Grabber)
-
Press Ctrl+Shift+G to toggle grab mode
-
Click elements to capture as tour steps
-
Drag to reorder steps in the panel
-
Click "Copy" to export JSON for AI
Export Format
{ "version": "1.0", "steps": [ { "order": 0, "element": "<button class="btn-primary">Get Started</button>", "componentTree": ["button", "Button", "Header", "App"] } ] }
AI Workflow
-
Capture elements with devtools
-
Copy the JSON export
-
Paste into AI with prompt: "Create a Flowsterix tour flow for these elements"
-
AI generates flow definition with proper data-tour-target selectors
Keyboard Shortcuts
Shortcut Action
Ctrl+Shift+G
Toggle grab mode
Esc
Cancel grab mode
Ctrl+Shift+M
Collapse/expand panel
Flows Tab
The Flows tab shows all registered flows and their stored state. Use it to:
-
View flow status - See which flows are idle, running, paused, completed, or cancelled
-
Inspect state - Check current step index, version, and step ID
-
Edit flow state - Modify stored JSON directly (useful for debugging)
-
Delete flow state - Clear stored state to reset a flow (cancels if running)
Features:
-
Live updates when active flow state changes
-
Shows "Active" badge for currently running flow
-
Confirmation required before deleting
Use cases:
-
Reset a flow to test from beginning
-
Debug unexpected flow behavior by inspecting stored state
-
Manually advance a stuck flow by editing stepIndex
-
Clear completed flows to re-trigger autoStart
Additional Resources
-
CSS Setup - Required shadcn CSS variables
-
Flow Patterns - Targeting, advance rules, waitFor
-
React Integration - Hooks, events, step content
-
Router Adapters - TanStack, React Router, Next.js
-
Advanced Patterns - Versions, storage, migrations
-
Mobile Support - Mobile drawer, snap points, gestures
Examples
-
Basic Flow - Simple 3-step onboarding
-
Async Content - waitFor patterns
-
Lifecycle Hooks - UI synchronization
-
Router Sync - All 4 router adapters