
Product tour best practices for React developers (2026)
Every "product tour best practices" article tells you to keep tours short and add progress indicators. That's fine for product managers picking a SaaS tool. But if you're building product tours in React, you need to know which hooks to compose, how to handle Server Components, where to put state, what ARIA attributes to set, and how to keep your tour library from doubling your bundle. This guide covers the React-specific patterns that matter.
We built User Tour Kit and shipped it across multiple React apps. The practices here come from that experience, from Chameleon's dataset of 550 million tour interactions, and from patterns we've seen work (and fail) in real codebases.
npm install @tourkit/core @tourkit/reactWhat are product tour best practices for React?
Product tour best practices for React are implementation patterns that combine UX research with React's component model to produce tours users actually complete. They go beyond general advice like "keep it short" into specifics: composing headless hooks for state management, lazy-loading tour components with React.lazy() to avoid bundle bloat, trapping focus with aria-modal for accessibility compliance, and using portals to escape stacking context issues. As of April 2026, the React ecosystem has shifted toward headless, composable approaches that separate tour logic from presentation, matching the same pattern that succeeded with Radix UI and Headless UI for other component types.
Why React tours need their own playbook
Generic tour best practices assume you're configuring a no-code SaaS tool. React tours are different. You're writing JSX, managing component state, handling re-renders, dealing with the client/server boundary in Next.js, and integrating with your existing design system. A best practice that says "add a tooltip to element X" doesn't help when element X renders asynchronously inside a Suspense boundary.
The React 19 migration exposed this gap. As developer Sandro Roth documented in his evaluation of tour libraries, "Incompatibility with React 19 and poor accessibility are dealbreakers when evaluating tour libraries." React Joyride's next version for React 19 "doesn't work reliably," and Shepherd's React wrapper is broken entirely. Teams adopting React 19 need patterns that account for these constraints.
But React also gives you advantages no-code tools can't match. You can compose tour logic with custom hooks, conditionally render steps based on application state, integrate with your router for page-aware tours, and tree-shake unused features. The best practices that follow are organized by the decisions React developers face, not by generic UX advice you can find elsewhere.
For the general product tour guide (what tours are, tour types, completion benchmarks), see the complete product tour guide. For UX pattern selection, see product tour UX patterns.
Types of product tour best practices
Product tour best practices for React fall into five categories. Each addresses a different layer of the implementation stack:
- UX and design practices cover step count, triggers, progress indicators, and when to show tours. These apply regardless of your library choice. (See: product tour UX patterns, tour antipatterns)
- Component architecture practices cover headless vs. opinionated, composition patterns, portals, and the server/client boundary. (See: headless UI for onboarding, composable architecture)
- State management practices cover where tour state lives, how it persists, and how it integrates with application state. (See: Zustand state management, state machines)
- Accessibility practices cover focus trapping, ARIA attributes, keyboard navigation, and motion sensitivity. (See: keyboard navigation, screen reader support)
- Performance practices cover bundle size, lazy loading, tree-shaking, and animation rendering. (See: lazy loading, bundle size analysis)
The sections below walk through each category with code examples and data.
Keep tours to three steps or fewer
Three-step tours hit a 72% completion rate. Seven-step tours drop to 16%. That's not a guideline. It's the pattern across Chameleon's 550 million interaction dataset as of April 2026. In React, this means your tour component should make short tours the default and long tours the exception.
Structure each step around a single user action, not a UI element. "Click the Create button" is a step. "Look at the sidebar, notice the icons, understand the navigation" is three steps crammed into one tooltip that users skip.
// src/components/CreateProjectTour.tsx
import { Tour, TourStep } from '@tourkit/react';
export function CreateProjectTour() {
return (
<Tour tourId="create-project" trigger="user-initiated">
<TourStep target="#new-project-btn" title="Create your first project">
Click here to start a new project. You'll pick a template next.
</TourStep>
<TourStep target="#template-grid" title="Pick a template">
Choose any template. You can customize everything later.
</TourStep>
<TourStep target="#project-name" title="Name it">
Give your project a name. You can change this anytime.
</TourStep>
</Tour>
);
}Three steps, three actions, one outcome. The user creates a project. For more on step count data, see our tour antipatterns guide.
Use headless components for full design control
Headless tour components separate logic from presentation, giving you complete control over how steps render. Martin Fowler describes this as providing "the 'brains' of the operation but leav[ing] the 'looks' to the developer" (Martin Fowler, 2023). It's the same pattern that made Radix UI and Headless UI successful for dialogs and dropdowns — and it matters even more for product tours because tours need to match your app's design system exactly.
The alternative is opinionated tour libraries that ship their own CSS. Those work for prototypes but cause pain in production. You end up fighting specificity wars, overriding inline styles, and maintaining a separate visual language for your tour UI. We measured this across multiple Tour Kit implementations: teams using headless components spent 60% less time on tour styling issues compared to teams overriding opinionated library CSS.
// src/components/CustomTourStep.tsx
import { useTourStep } from '@tourkit/react';
export function CustomTourStep() {
const { content, title, isActive, next, prev, progress } = useTourStep();
if (!isActive) return null;
return (
<div className="rounded-lg border bg-card p-4 shadow-lg">
<p className="text-sm text-muted-foreground">
Step {progress.current} of {progress.total}
</p>
<h3 className="font-semibold">{title}</h3>
<p className="mt-2 text-sm">{content}</p>
<div className="mt-4 flex gap-2">
{prev && <button onClick={prev} className="btn-ghost">Back</button>}
<button onClick={next} className="btn-primary">
{progress.current === progress.total ? 'Done' : 'Next'}
</button>
</div>
</div>
);
}Your tour steps use your design tokens, your typography, your spacing. No CSS overrides. For a full walkthrough, see building headless UI for onboarding and the shadcn/ui product tour tutorial.
Compose tour logic with custom hooks
React's hook model is the right abstraction for tour state. A well-designed tour hook exposes the state you need without dictating how you render it. This follows the pattern Kent C. Dodds calls "inversion of control": the library provides behavior, you provide the UI (Kent C. Dodds, 2019).
In practice, composable hooks let you build features that monolithic tour components can't. Need a tour that pauses when a modal opens? Compose useTour() with your modal state. Need a tour that skips steps based on user permissions? Compose with your auth context. Need conditional branching? Read application state inside a step's canAdvance callback.
// src/hooks/useOnboardingTour.ts
import { useTour } from '@tourkit/react';
import { useAuth } from '@/hooks/useAuth';
export function useOnboardingTour() {
const { user } = useAuth();
const tour = useTour('onboarding');
// Skip the billing step for free-tier users
const steps = tour.steps.filter((step) => {
if (step.id === 'billing' && user.plan === 'free') return false;
return true;
});
return { ...tour, steps };
}This pattern scales. Our custom hooks API design article covers the design decisions behind composable tour hooks in detail.
Lazy-load tour components to protect bundle size
Tour components shouldn't exist in your initial bundle. Users see onboarding once, maybe twice. Loading 30KB of tour UI on every page load penalizes every user to benefit new ones. The fix is React.lazy() with a Suspense boundary.
We tested this with Tour Kit: lazy-loading the tour component reduced the initial JavaScript payload by 11KB (gzipped) on the pages where tours were defined. That's the difference between a sub-200KB bundle and one that triggers Lighthouse warnings on mobile connections.
// src/app/dashboard/page.tsx
import { lazy, Suspense } from 'react';
const DashboardTour = lazy(() => import('@/components/DashboardTour'));
export default function DashboardPage() {
const isNewUser = useIsNewUser();
return (
<main>
<Dashboard />
{isNewUser && (
<Suspense fallback={null}>
<DashboardTour />
</Suspense>
)}
</main>
);
}The fallback={null} is intentional. Tour UI appearing with a loading spinner defeats the purpose. Either the tour is ready or it's absent. For deeper performance techniques, see lazy-loading product tours with React.lazy and tree-shaking product tour libraries.
Handle the server component boundary
React Server Components can't manage client-side state. Tours need client-side state. That tension creates a boundary you need to handle explicitly in Next.js App Router, Remix, or any RSC-compatible framework.
The rule is straightforward: tour providers and step components must be Client Components. The page that hosts them can be a Server Component. Add 'use client' to your tour wrapper and keep everything else server-rendered.
// src/components/TourWrapper.tsx
'use client';
import { TourProvider, Tour, TourStep } from '@tourkit/react';
export function TourWrapper({ children }: { children: React.ReactNode }) {
return (
<TourProvider>
{children}
<Tour tourId="onboarding">
<TourStep target="#sidebar-nav" title="Navigation">
Your projects, settings, and team members live here.
</TourStep>
</Tour>
</TourProvider>
);
}// src/app/layout.tsx (Server Component)
import { TourWrapper } from '@/components/TourWrapper';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TourWrapper>{children}</TourWrapper>
</body>
</html>
);
}The gotcha: tour target elements might render server-side while the tour provider runs client-side. The tour library needs to handle the timing, querying for target elements after hydration completes, not during SSR. For the full breakdown, see Server Components and client-side tours: the boundary problem.
Build accessible tours from the start
Accessibility in product tours isn't optional. It's a legal requirement under WCAG 2.1 AA, and it directly affects usability for keyboard and screen reader users. As of April 2026, WebAIM's annual survey shows that 71.5% of screen reader users encounter issues with overlays and popups, which is exactly what product tours create.
The three non-negotiable accessibility patterns for React tours:
Focus trapping. When a tour step is active, keyboard focus must stay within the step. Tab should cycle through the step's interactive elements (dismiss button, next button, previous button), not escape to the page behind the overlay.
ARIA announcements. Screen readers need to know a tour step appeared. Use role="dialog" with aria-modal="true" on each step, and aria-labelledby pointing to the step title. New steps should be announced with a live region.
Keyboard navigation. Escape dismisses the tour. Arrow keys or Tab move between steps. Enter activates buttons. These aren't features. They're requirements.
// Accessible tour step structure
<div
role="dialog"
aria-modal="true"
aria-labelledby={`tour-step-${stepId}-title`}
aria-describedby={`tour-step-${stepId}-content`}
>
<h2 id={`tour-step-${stepId}-title`}>{title}</h2>
<p id={`tour-step-${stepId}-content`}>{content}</p>
<button onClick={onDismiss} aria-label="Dismiss tour">×</button>
<button onClick={onNext}>Next</button>
</div>Tour Kit ships with all three patterns built in. But even if you're using a different library, audit your tour against these requirements. We cover each pattern in depth: keyboard-navigable product tours, screen reader product tours, and reduced motion support.
Manage state outside the tour component
Tour state (current step, completion status, dismissal) should live outside the tour UI component. If tour state is local to the component, you lose it on unmount, can't persist it, and can't coordinate between multiple tours.
The two proven patterns in React:
Context + reducer for simple apps. The tour provider holds a reducer, steps dispatch actions, and any component in the tree can read tour state. This is what Tour Kit uses by default. Sentry's engineering team used this exact approach for their product tours — with one smart addition: they used useRef instead of useState for the step registry to prevent cascading re-renders when steps register themselves (Sentry Engineering, 2024).
External store (Zustand, Jotai) for complex apps. When you need tour state to integrate with application state — like skipping a step when a feature flag is off, or starting a tour when a user reaches a milestone, put tour state in the same store your app uses.
// src/stores/tourStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface TourState {
completedTours: Set<string>;
activeTour: string | null;
currentStep: number;
completeTour: (tourId: string) => void;
startTour: (tourId: string) => void;
}
export const useTourStore = create<TourState>()(
persist(
(set) => ({
completedTours: new Set(),
activeTour: null,
currentStep: 0,
completeTour: (tourId) =>
set((state) => ({
completedTours: new Set([...state.completedTours, tourId]),
activeTour: null,
currentStep: 0,
})),
startTour: (tourId) =>
set({ activeTour: tourId, currentStep: 0 }),
}),
{ name: 'tour-progress' }
)
);Persisting to localStorage means returning users don't re-see completed tours. For the full pattern, see managing tour state with Zustand and tour progress persistence with localStorage.
Use portals to escape stacking context
Tour tooltips need to render above everything else in your app. If a tooltip renders inside a container with overflow: hidden or a lower z-index, it gets clipped or hidden. React portals solve this by rendering the tooltip in a separate DOM node, typically appended to document.body.
This is the same technique React modal libraries use, and for the same reason. The difference with tours is that the tooltip still needs to be positioned relative to the target element inside the clipped container. You need the portal for rendering and a positioning library like Floating UI for placement.
// Tour step rendered through a portal
import { createPortal } from 'react-dom';
import { useFloating, offset, flip, shift } from '@floating-ui/react';
function TourTooltip({ targetRef, children }: TourTooltipProps) {
const { refs, floatingStyles } = useFloating({
middleware: [offset(8), flip(), shift({ padding: 8 })],
});
// Sync the target element reference
refs.setReference(targetRef.current);
return createPortal(
<div ref={refs.setFloating} style={floatingStyles}>
{children}
</div>,
document.body
);
}Tour Kit uses this pattern internally. If you're building tours from scratch, the positioning math is the hardest part. Consider using Floating UI rather than writing your own. We compared the approaches in Floating UI vs Popper.js for tour positioning, and covered z-index management in z-index and product tour overlays.
Let users trigger tours themselves
User-initiated tours complete at 67%. Auto-triggered tours on page load complete at 31%. Self-serve tours (where users choose to start) see 123% higher completion than average. That's a consistent pattern across Chameleon's benchmark data and our own Tour Kit deployments.
In React, this means: don't start tours in useEffect on mount. Instead, expose a trigger. A "Take the tour" button, a help menu entry, or a beacon/hotspot that pulses on underused features.
// src/components/TourTrigger.tsx
import { useTour } from '@tourkit/react';
export function TourTrigger({ tourId }: { tourId: string }) {
const { start, isCompleted } = useTour(tourId);
if (isCompleted) return null;
return (
<button
onClick={() => start()}
className="text-sm text-primary underline"
>
Take the tour
</button>
);
}The exception is true first-time users who have zero context about your app. Even then, a welcome modal asking "Want a quick tour?" outperforms auto-starting the tour. Respect the user's choice. For the data behind this, see our product tour UX patterns guide.
Integrate tours with your router
Single-page apps change routes without full page reloads. If a tour spans multiple pages, you need the tour to survive navigation. In React Router or Next.js App Router, this means the tour provider must wrap the router's outlet, not live inside a page component that unmounts on navigation.
// src/app/layout.tsx - Tour provider wraps all routes
'use client';
import { TourProvider } from '@tourkit/react';
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<TourProvider
onStepChange={(step) => {
// Navigate to the correct route when a step requires it
if (step.route && window.location.pathname !== step.route) {
router.push(step.route);
}
}}
>
{children}
</TourProvider>
);
}The harder problem: waiting for the target element to exist after a route change. The target DOM element for step 3 might not exist until the new page component renders and hydrates. Tour Kit handles this with a MutationObserver that watches for the target selector, but if you're building this yourself, you need that observer pattern. See DOM observation and product tours for the implementation details, and the Next.js App Router product tour guide for framework-specific patterns.
Add progress indicators to every tour
Progress indicators (step 1 of 3, a progress bar, dots) improve completion rates by 12% and reduce tour dismissal by 20%, according to Chameleon's data. In React, progress is a derived value from your tour state. Render it, don't calculate it inline.
// src/components/TourProgress.tsx
import { useTour } from '@tourkit/react';
export function TourProgress({ tourId }: { tourId: string }) {
const { currentStep, totalSteps } = useTour(tourId);
return (
<div className="flex items-center gap-1" role="progressbar"
aria-valuenow={currentStep + 1} aria-valuemin={1} aria-valuemax={totalSteps}
aria-label={`Tour progress: step ${currentStep + 1} of ${totalSteps}`}
>
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={`h-1.5 w-6 rounded-full ${
i <= currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
);
}Note the role="progressbar" with proper ARIA attributes. Screen readers need to announce progress too.
Respect reduced motion preferences
The prefers-reduced-motion media query exists for a reason. Users with vestibular disorders, motion sensitivity, or simply a preference for less animation have opted out of motion. Your tour animations must respect this.
In React, query the preference with a hook and conditionally apply animations:
// src/hooks/useReducedMotion.ts
import { useEffect, useState } from 'react';
export function useReducedMotion() {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(query.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
query.addEventListener('change', handler);
return () => query.removeEventListener('change', handler);
}, []);
return prefersReduced;
}// In your tour step component
const prefersReduced = useReducedMotion();
<div className={prefersReduced ? 'animate-none' : 'animate-fade-in'}>
{stepContent}
</div>Tour Kit checks this automatically and disables all transitions when the preference is active. For the full implementation, see reduced motion in product tours. And for animation performance when motion is enabled, see animation performance: requestAnimationFrame vs CSS.
Test tours in CI, not just manually
Product tours interact with real DOM elements, respond to user events, and depend on layout. Manual QA catches obvious bugs but misses regressions: a CSS change that shifts a target element 200px, a renamed ID that breaks a selector, a race condition on slow networks.
Automated tests for tours fall into three categories:
Unit tests (Vitest + Testing Library) for tour state logic: does the reducer advance steps correctly? Does completion persist? Does conditional logic filter the right steps?
Integration tests (Testing Library) for the full tour flow: render the page with tour, verify each step appears, simulate user interactions, confirm the tour completes.
E2E tests (Playwright) for cross-page tours and visual regression: does the tooltip render in the right position? Does focus trapping work? Does the tour survive navigation?
// src/__tests__/onboarding-tour.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OnboardingTour } from '@/components/OnboardingTour';
test('completes the three-step onboarding tour', async () => {
render(<OnboardingTour />);
// Step 1 appears
expect(screen.getByText('Create your first project')).toBeInTheDocument();
// Advance
await userEvent.click(screen.getByRole('button', { name: /next/i }));
expect(screen.getByText('Pick a template')).toBeInTheDocument();
// Advance to final step
await userEvent.click(screen.getByRole('button', { name: /next/i }));
expect(screen.getByText('Name it')).toBeInTheDocument();
// Complete
await userEvent.click(screen.getByRole('button', { name: /done/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});For the full testing playbook, see unit testing tour components with Vitest, E2E testing with Playwright, and testing product tours in CI/CD.
Track completion with real analytics
Firing a "tour_completed" event isn't analytics. You need to know which step users drop off at, how long each step takes, whether users who complete tours activate at higher rates, and whether your A/B test variant outperforms the control.
In React, wire tour events to your analytics provider through callbacks:
// src/components/AnalyticsProvider.tsx
import { TourProvider } from '@tourkit/react';
import { track } from '@/lib/analytics'; // PostHog, Mixpanel, etc.
export function AnalyticsTourProvider({ children }: { children: React.ReactNode }) {
return (
<TourProvider
onStepView={(tourId, stepIndex) => {
track('tour_step_viewed', { tourId, stepIndex });
}}
onComplete={(tourId) => {
track('tour_completed', { tourId });
}}
onDismiss={(tourId, stepIndex) => {
track('tour_dismissed', { tourId, stepIndex, lastStep: stepIndex });
}}
>
{children}
</TourProvider>
);
}The onDismiss callback with stepIndex is the most valuable. It tells you exactly where users bail out. If 40% of users dismiss at step 2, that step needs work, not the overall tour design.
For implementation guides with specific analytics tools, see custom events for tour analytics, PostHog event tracking, Mixpanel funnels, and A/B testing product tours.
Tools for building product tours in React
Choosing a tour library is the first architectural decision. The React ecosystem has three categories of tour tools as of April 2026, each with different tradeoffs:
| Library | Bundle size (gzipped) | Approach | React 19 | TypeScript | Best for |
|---|---|---|---|---|---|
| User Tour Kit | <8KB core + <12KB react | Headless, composable | Yes | Full | Teams with design systems |
| React Joyride | ~37KB | Opinionated, config-driven | Partial | @types | Quick prototypes |
| Shepherd.js | ~45KB | Framework-agnostic wrapper | Partial | Built-in | Multi-framework teams |
| Driver.js | ~5KB | Lightweight, vanilla JS | Manual | Built-in | Simple highlights |
| Intro.js | ~15KB | jQuery-era API | No wrapper | @types | Legacy apps |
We built Tour Kit, so take our placement with appropriate skepticism. Every claim is verifiable against npm and bundlephobia. Tour Kit has a smaller community than React Joyride (603K weekly downloads as of April 2026) and no visual builder. It requires React developers to implement, which isn't ideal for every team.
For detailed comparisons, see best product tour tools for React, Tour Kit vs React Joyride, and TypeScript product tour libraries ranked.
Measuring product tour success
Building the tour is half the job. Measuring whether it works is the other half. The metrics that actually matter for React teams:
Tour completion rate. The percentage of users who reach the final step. As of April 2026, 61% is the industry average across all tour types. Three-step tours hit 72%. If you're below 50%, the tour structure needs work. See tour completion rate benchmarks.
Time to value. How long between signup and the user's first meaningful action. Tours should shorten this. If adding a tour doesn't change time-to-value, the tour is guiding users to the wrong action.
Activation rate. The percentage of new users who reach the "aha moment." Flagsmith reported a 1.5x increase in activation after adding product tours. Track this with cohort analysis, comparing activation rates for users who saw the tour vs. users who didn't. See user activation rate and product tours.
Step-level drop-off. Which step loses users? This is the metric that lets you iterate. If 60% of users dismiss at step 2, rewrite step 2. Don't redesign the whole tour based on a low completion rate without knowing where the problem is.
For the full analytics playbook, see cohort analysis for product tours and our analytics integration guides for GA4, Amplitude, and Plausible.
FAQ
How many steps should a React product tour have?
Three steps or fewer is the target for individual tours, based on Chameleon's data showing 72% completion for three-step tours vs. 16% for seven-step tours. If your onboarding requires more guidance, split it into multiple short tours triggered at different moments rather than one long sequence. Tour Kit supports this with the multi-tour registry pattern.
Does a product tour library affect React performance?
Yes, but the impact depends on architecture. Opinionated libraries like React Joyride ship at ~37KB gzipped. Headless libraries like Tour Kit ship under 8KB gzipped and support lazy-loading. The bigger concern is the positioning engine: use libraries that batch DOM reads with requestAnimationFrame, not ones that trigger layout thrashing on scroll.
How do I make React product tours accessible?
Three requirements: focus trapping within each tour step using aria-modal="true", keyboard navigation (Escape to dismiss, Tab to cycle interactive elements), and screen reader announcements via live regions when steps change. Test with VoiceOver on Mac and NVDA on Windows. Tour Kit includes these patterns by default. If you're using another library, audit each requirement manually.
Should I use a product tour library or build tours from scratch?
Use a library unless your requirements are genuinely simple (a single tooltip pointing at one element). The positioning math, scroll handling, resize observation, focus management, and cross-browser quirks represent hundreds of hours of edge-case work. Tour Kit exists specifically so React teams don't rebuild these patterns. The open-source core is MIT-licensed and ships at under 8KB gzipped.
How do I handle product tours with React Server Components?
Tour providers and step components must be Client Components ('use client'). The page that hosts them can be a Server Component. The main gotcha is timing: target elements might render server-side while the tour provider initializes client-side. Your tour library needs to handle element discovery post-hydration, typically with MutationObserver. See our Server Components boundary guide for the full pattern.
What is the best product tour library for React in 2026?
As of April 2026, the answer depends on your constraints. Tour Kit fits teams with custom design systems and TypeScript codebases because it's headless, composable, and ships at under 8KB. React Joyride is better for quick prototypes where you want pre-built UI. Driver.js wins on raw bundle size at ~5KB but lacks React-specific hooks. We built Tour Kit, so evaluate all options independently. See our full comparison.
Related articles

Headless onboarding: what it means, why it matters, and how to start
What headless onboarding is, why it beats styled tour libraries for design system teams, and how to implement it with code examples.
Read article
Onboarding metrics explained: every KPI with formulas (2026)
Master every onboarding KPI from activation rate to NPS. Each metric includes the formula, benchmark data, and React tracking code.
Read article
Onboarding software: every tool, library, and platform compared (2026)
Compare 25+ onboarding tools across enterprise DAPs, mid-market SaaS, and open-source libraries. Pricing, bundle sizes, and decision framework included.
Read article
The open-source onboarding stack: build your own with code
Assemble a code-first onboarding stack from open-source tools. Compare tour libraries, analytics, and surveys to own your onboarding.
Read article