flowsterix-best-practices
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 onlypeek(~40% of expanded) - Optional middle state for summariesexpanded(auto) - Sized to content, capped atmaxHeightRatio
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
maxHeightRatioof viewport (default 85%) - No flicker - Starts small, animates up once content is measured
- Resets to
expandedon step transitions - Content crossfades between steps
- Safe area insets for notched phones
aria-liveannouncement 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-targetattributes - 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
waitForfor 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
onResumehooks - 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-450msfor most landing pages. Start with350ms. - Keep page-level CSS smooth scroll if you want; when
scrollDurationMsis 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,centerfor guided storytelling, orstartwhen 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 spacingStepTitle- Semibold heading (supportssize="lg"for welcome screens)StepText- Muted paragraph textStepHint- 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
1. 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()
}
}
2. 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>
),
}
3. 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: ...
}
4. 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
onResumewhen you haveonEnter- Users may reload the page mid-tour - Check state before acting - Don't toggle already-open menus
- Use
setTimeoutfor 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+Gto 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-targetselectors
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