| name | User Onboarding Patterns |
| description | Comprehensive guide to UX onboarding patterns including progressive disclosure, tooltips, walkthroughs, empty states, first-run experiences, and interactive tutorials with React implementations |
User Onboarding Patterns
Overview
Effective onboarding is the difference between user retention and abandonment. The goal is to guide users to their first "aha moment" as quickly as possible while minimizing cognitive load. This guide provides battle-tested patterns with React implementations optimized for rapid prototyping in 6-day sprints.
Core Principle: Show users value before asking for effort. Progressive disclosure beats overwhelming feature tours. Context beats documentation.
When to Use
Pattern Selection Matrix
| Pattern | Best For | Time to Value | Development Time | User Effort |
|---|---|---|---|---|
| Empty States | First-time users, new features | Immediate | 2-4 hours | None |
| Tooltips | Feature discovery, inline help | Immediate | 1-2 hours | Minimal |
| Progressive Disclosure | Complex features | Gradual | 4-8 hours | Low |
| Product Tours | Multi-step workflows | 2-5 minutes | 8-16 hours | Medium |
| Interactive Tutorials | Power users, complex tools | 5-15 minutes | 16-40 hours | High |
| Checklists | Setup/configuration tasks | Ongoing | 4-8 hours | Medium |
Onboarding Goals by Product Type
SaaS Apps: Get to first value action (create project, send message, etc.) E-commerce: Complete first purchase or add to wishlist Social Apps: Connect with others, create first post Tools/Productivity: Complete core workflow once
Pattern 1: Empty States (Highest ROI)
When to Use
- User first arrives at feature/section
- No data exists yet
- Error states that need recovery
- Search returns no results
Implementation
// components/EmptyState.tsx
import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
};
secondaryAction?: {
label: string;
onClick: () => void;
};
illustration?: ReactNode;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
secondaryAction,
illustration,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
{illustration || (
<div className="mb-4 p-4 bg-gray-100 rounded-full">
<Icon className="w-12 h-12 text-gray-400" />
</div>
)}
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
<p className="text-gray-600 max-w-md mb-6">{description}</p>
<div className="flex gap-3">
{action && (
<button
onClick={action.onClick}
className={`px-6 py-3 rounded-lg font-medium ${
action.variant === 'secondary'
? 'bg-gray-200 text-gray-900'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{action.label}
</button>
)}
{secondaryAction && (
<button
onClick={secondaryAction.onClick}
className="px-6 py-3 text-gray-600 hover:text-gray-900"
>
{secondaryAction.label}
</button>
)}
</div>
</div>
);
}
// Usage examples
import { FileText, Search, Users, AlertCircle } from 'lucide-react';
import { EmptyState } from '@/components/EmptyState';
import { useRouter } from 'next/navigation';
// No documents yet
export function DocumentsEmpty() {
const router = useRouter();
return (
<EmptyState
icon={FileText}
title="No documents yet"
description="Create your first document to get started with collaborative editing."
action={{
label: "Create Document",
onClick: () => router.push('/documents/new'),
}}
secondaryAction={{
label: "Import from Google Docs",
onClick: () => router.push('/import'),
}}
/>
);
}
// Search no results
export function SearchEmpty({ query }: { query: string }) {
return (
<EmptyState
icon={Search}
title="No results found"
description={`We couldn't find anything matching "${query}". Try different keywords or check your spelling.`}
action={{
label: "Clear Search",
onClick: () => window.location.href = '/search',
variant: 'secondary',
}}
/>
);
}
// Error state
export function ErrorState({ error, retry }: { error: string; retry: () => void }) {
return (
<EmptyState
icon={AlertCircle}
title="Something went wrong"
description={error || "We're having trouble loading this page. Please try again."}
action={{
label: "Try Again",
onClick: retry,
}}
secondaryAction={{
label: "Contact Support",
onClick: () => window.open('/support', '_blank'),
}}
/>
);
}
Progressive Empty States
// components/ProgressiveEmptyState.tsx
import { useState, useEffect } from 'react';
interface Step {
id: string;
title: string;
description: string;
completed: boolean;
}
export function OnboardingChecklist({ steps: initialSteps }: { steps: Step[] }) {
const [steps, setSteps] = useState(initialSteps);
const completedCount = steps.filter((s) => s.completed).length;
const progress = (completedCount / steps.length) * 100;
if (completedCount === steps.length) {
return null; // Hide when complete
}
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-6 mb-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold">Get Started</h3>
<p className="text-sm text-gray-600">
{completedCount} of {steps.length} completed
</p>
</div>
<button className="text-sm text-gray-500 hover:text-gray-700">
Dismiss
</button>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-2 mb-4">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Steps */}
<div className="space-y-3">
{steps.map((step) => (
<div
key={step.id}
className={`flex items-start gap-3 ${
step.completed ? 'opacity-50' : ''
}`}
>
<input
type="checkbox"
checked={step.completed}
onChange={() => {
setSteps(steps.map((s) =>
s.id === step.id ? { ...s, completed: !s.completed } : s
));
}}
className="mt-1"
/>
<div>
<p className={`font-medium ${step.completed ? 'line-through' : ''}`}>
{step.title}
</p>
<p className="text-sm text-gray-600">{step.description}</p>
</div>
</div>
))}
</div>
</div>
);
}
// Usage
function Dashboard() {
return (
<div>
<OnboardingChecklist
steps={[
{
id: '1',
title: 'Create your profile',
description: 'Add a photo and bio',
completed: false,
},
{
id: '2',
title: 'Invite team members',
description: 'Collaborate with your team',
completed: false,
},
{
id: '3',
title: 'Create your first project',
description: 'Start organizing your work',
completed: false,
},
]}
/>
{/* Rest of dashboard */}
</div>
);
}
Pattern 2: Contextual Tooltips
When to Use
- Inline feature discovery
- Form field help text
- Icon button explanations
- Progressive feature rollout
Implementation
// components/Tooltip.tsx
import { ReactNode, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface TooltipProps {
content: ReactNode;
children: ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
showArrow?: boolean;
}
export function Tooltip({
content,
children,
placement = 'top',
delay = 200,
showArrow = true,
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
const updatePosition = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const tooltipOffset = 10;
const positions = {
top: {
top: rect.top - tooltipOffset,
left: rect.left + rect.width / 2,
},
bottom: {
top: rect.bottom + tooltipOffset,
left: rect.left + rect.width / 2,
},
left: {
top: rect.top + rect.height / 2,
left: rect.left - tooltipOffset,
},
right: {
top: rect.top + rect.height / 2,
left: rect.right + tooltipOffset,
},
};
setPosition(positions[placement]);
};
const handleMouseEnter = () => {
timeoutRef.current = setTimeout(() => {
setIsVisible(true);
updatePosition();
}, delay);
};
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsVisible(false);
};
useEffect(() => {
if (isVisible) {
window.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
};
}
}, [isVisible]);
return (
<>
<div
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="inline-block"
>
{children}
</div>
{isVisible &&
createPortal(
<div
className="fixed z-50 px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-lg max-w-xs"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
transform: placement === 'top' || placement === 'bottom'
? 'translateX(-50%)'
: 'translateY(-50%)',
}}
>
{content}
{showArrow && (
<div
className="absolute w-2 h-2 bg-gray-900 transform rotate-45"
style={{
...(placement === 'top' && { bottom: '-4px', left: '50%', marginLeft: '-4px' }),
...(placement === 'bottom' && { top: '-4px', left: '50%', marginLeft: '-4px' }),
...(placement === 'left' && { right: '-4px', top: '50%', marginTop: '-4px' }),
...(placement === 'right' && { left: '-4px', top: '50%', marginTop: '-4px' }),
}}
/>
)}
</div>,
document.body
)}
</>
);
}
// Feature highlight tooltip with persistent badge
import { Tooltip } from '@/components/Tooltip';
import { Sparkles } from 'lucide-react';
export function NewFeatureButton() {
const [hasSeenFeature, setHasSeenFeature] = useState(false);
return (
<div className="relative">
{!hasSeenFeature && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
</span>
)}
<Tooltip
content={
<div>
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4" />
<span className="font-semibold">New Feature!</span>
</div>
<p>Try our new AI-powered suggestions</p>
</div>
}
placement="bottom"
>
<button
onClick={() => setHasSeenFeature(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
AI Suggestions
</button>
</Tooltip>
</div>
);
}
Pattern 3: Product Tours / Walkthroughs
When to Use
- Multi-step feature onboarding
- Complex workflows
- New dashboard sections
- Critical feature adoption
Implementation with React Joyride
// hooks/useProductTour.ts
import { useState, useEffect } from 'react';
import Joyride, { Step, CallBackProps, STATUS } from 'react-joyride';
interface TourConfig {
id: string;
steps: Step[];
showOnFirstVisit?: boolean;
}
export function useProductTour(config: TourConfig) {
const [run, setRun] = useState(false);
const storageKey = `tour-completed-${config.id}`;
useEffect(() => {
if (config.showOnFirstVisit) {
const hasCompletedTour = localStorage.getItem(storageKey);
if (!hasCompletedTour) {
// Delay to ensure DOM is ready
setTimeout(() => setRun(true), 500);
}
}
}, [config.id, config.showOnFirstVisit, storageKey]);
const handleJoyrideCallback = (data: CallBackProps) => {
const { status } = data;
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
setRun(false);
localStorage.setItem(storageKey, 'true');
}
};
const startTour = () => setRun(true);
const resetTour = () => {
localStorage.removeItem(storageKey);
setRun(true);
};
return {
run,
startTour,
resetTour,
handleJoyrideCallback,
steps: config.steps,
};
}
// components/DashboardTour.tsx
import Joyride from 'react-joyride';
import { useProductTour } from '@/hooks/useProductTour';
export function DashboardTour() {
const tour = useProductTour({
id: 'dashboard-tour',
showOnFirstVisit: true,
steps: [
{
target: '[data-tour="projects"]',
content: (
<div>
<h3 className="text-lg font-semibold mb-2">Your Projects</h3>
<p>All your projects are listed here. Click any project to open it.</p>
</div>
),
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="create-project"]',
content: 'Click here to create a new project and start collaborating.',
placement: 'bottom',
},
{
target: '[data-tour="team-members"]',
content: 'Invite team members to collaborate on projects together.',
placement: 'left',
},
{
target: '[data-tour="notifications"]',
content: 'Stay updated with notifications about project activity.',
placement: 'bottom',
},
],
});
return (
<Joyride
steps={tour.steps}
run={tour.run}
continuous
showProgress
showSkipButton
callback={tour.handleJoyrideCallback}
styles={{
options: {
primaryColor: '#3b82f6',
zIndex: 10000,
},
tooltip: {
fontSize: 16,
padding: 20,
},
buttonNext: {
backgroundColor: '#3b82f6',
fontSize: 14,
padding: '8px 16px',
},
buttonBack: {
marginRight: 10,
color: '#6b7280',
},
}}
/>
);
}
// Usage in Dashboard component
export function Dashboard() {
return (
<div>
<DashboardTour />
<div className="grid grid-cols-3 gap-4">
<div data-tour="projects">
{/* Projects list */}
</div>
<button data-tour="create-project">
Create Project
</button>
<div data-tour="team-members">
{/* Team members */}
</div>
<div data-tour="notifications">
{/* Notifications */}
</div>
</div>
</div>
);
}
Custom Tour Component (Zero Dependencies)
// components/TourSpotlight.tsx
import { useState } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
interface TourStep {
target: string; // CSS selector
title: string;
content: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
interface TourSpotlightProps {
steps: TourStep[];
onComplete: () => void;
onSkip: () => void;
}
export function TourSpotlight({ steps, onComplete, onSkip }: TourSpotlightProps) {
const [currentStep, setCurrentStep] = useState(0);
const step = steps[currentStep];
const nextStep = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
onComplete();
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const element = document.querySelector(step.target);
if (!element) return null;
const rect = element.getBoundingClientRect();
return (
<>
{/* Overlay with spotlight */}
<div className="fixed inset-0 z-50">
<svg className="absolute inset-0 w-full h-full">
<defs>
<mask id="spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<rect
x={rect.left - 4}
y={rect.top - 4}
width={rect.width + 8}
height={rect.height + 8}
rx="8"
fill="black"
/>
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.7)"
mask="url(#spotlight-mask)"
/>
</svg>
{/* Highlighted border */}
<div
className="absolute border-4 border-blue-500 rounded-lg pointer-events-none animate-pulse"
style={{
top: rect.top - 4,
left: rect.left - 4,
width: rect.width + 8,
height: rect.height + 8,
}}
/>
{/* Tour card */}
<div
className="absolute bg-white rounded-lg shadow-2xl p-6 max-w-sm"
style={{
top: rect.bottom + 20,
left: rect.left,
}}
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold">{step.title}</h3>
<p className="text-sm text-gray-500">
Step {currentStep + 1} of {steps.length}
</p>
</div>
<button onClick={onSkip} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-700 mb-6">{step.content}</p>
<div className="flex justify-between items-center">
<button
onClick={prevStep}
disabled={currentStep === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
<div className="flex gap-1">
{steps.map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full ${
index === currentStep ? 'bg-blue-600' : 'bg-gray-300'
}`}
/>
))}
</div>
<button
onClick={nextStep}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg"
>
{currentStep === steps.length - 1 ? 'Finish' : 'Next'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</>
);
}
Pattern 4: Interactive Tutorials
When to Use
- Teaching complex workflows
- Power user features
- Interactive demos
- Sandbox environments
Implementation
// components/InteractiveTutorial.tsx
import { useState } from 'react';
import { Check, AlertCircle } from 'lucide-react';
interface TutorialTask {
id: string;
instruction: string;
validate: () => boolean;
hint?: string;
}
interface InteractiveTutorialProps {
title: string;
tasks: TutorialTask[];
onComplete: () => void;
}
export function InteractiveTutorial({
title,
tasks,
onComplete,
}: InteractiveTutorialProps) {
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
const [completedTasks, setCompletedTasks] = useState<string[]>([]);
const [showHint, setShowHint] = useState(false);
const currentTask = tasks[currentTaskIndex];
const isTaskCompleted = completedTasks.includes(currentTask.id);
const checkTask = () => {
if (currentTask.validate()) {
setCompletedTasks([...completedTasks, currentTask.id]);
if (currentTaskIndex < tasks.length - 1) {
setTimeout(() => {
setCurrentTaskIndex(currentTaskIndex + 1);
setShowHint(false);
}, 1000);
} else {
setTimeout(onComplete, 1000);
}
} else {
setShowHint(true);
}
};
return (
<div className="fixed bottom-8 right-8 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-4 rounded-t-lg">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm opacity-90">
{currentTaskIndex + 1} of {tasks.length} tasks
</p>
</div>
<div className="p-4">
{/* Progress */}
<div className="flex gap-1 mb-4">
{tasks.map((task, index) => (
<div
key={task.id}
className={`flex-1 h-1 rounded ${
completedTasks.includes(task.id)
? 'bg-green-500'
: index === currentTaskIndex
? 'bg-blue-500'
: 'bg-gray-200'
}`}
/>
))}
</div>
{/* Current task */}
<div className="mb-4">
<div className="flex items-start gap-3">
{isTaskCompleted ? (
<Check className="w-5 h-5 text-green-500 mt-1" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-blue-500 mt-1" />
)}
<p className={isTaskCompleted ? 'line-through text-gray-500' : 'text-gray-900'}>
{currentTask.instruction}
</p>
</div>
{showHint && currentTask.hint && (
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg flex gap-2">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
<p className="text-sm text-amber-900">{currentTask.hint}</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={checkTask}
disabled={isTaskCompleted}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg disabled:bg-green-500 disabled:cursor-not-allowed"
>
{isTaskCompleted ? 'Completed!' : 'Check Task'}
</button>
{!showHint && currentTask.hint && (
<button
onClick={() => setShowHint(true)}
className="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Hint
</button>
)}
</div>
</div>
</div>
);
}
// Usage example: Email app tutorial
export function EmailAppWithTutorial() {
const [showTutorial, setShowTutorial] = useState(true);
const [emailDraft, setEmailDraft] = useState({ to: '', subject: '', body: '' });
const [sentEmails, setSentEmails] = useState<any[]>([]);
const tutorialTasks: TutorialTask[] = [
{
id: 'compose',
instruction: 'Click the "Compose" button to start writing an email',
validate: () => document.querySelector('[data-compose-open]') !== null,
hint: 'Look for the blue "Compose" button in the top left',
},
{
id: 'recipient',
instruction: 'Enter a recipient email address',
validate: () => emailDraft.to.includes('@'),
hint: 'Type an email address in the "To" field',
},
{
id: 'subject',
instruction: 'Add a subject line to your email',
validate: () => emailDraft.subject.length > 0,
hint: 'Enter text in the "Subject" field',
},
{
id: 'send',
instruction: 'Click "Send" to send your first email',
validate: () => sentEmails.length > 0,
hint: 'Click the blue "Send" button at the bottom',
},
];
return (
<div>
{/* Email UI */}
<div className="p-4">
<button
onClick={() => setShowTutorial(true)}
data-compose-open={showTutorial}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Compose
</button>
{/* Compose form... */}
</div>
{showTutorial && (
<InteractiveTutorial
title="Send Your First Email"
tasks={tutorialTasks}
onComplete={() => {
setShowTutorial(false);
alert('Tutorial complete! You\'re all set.');
}}
/>
)}
</div>
);
}
Pattern 5: Progressive Disclosure
When to Use
- Complex forms with many fields
- Feature-rich interfaces
- Settings pages
- Multi-tier pricing
Implementation
// components/ProgressiveForm.tsx
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
interface FormSection {
id: string;
title: string;
description?: string;
fields: React.ReactNode;
isAdvanced?: boolean;
}
export function ProgressiveForm({ sections }: { sections: FormSection[] }) {
const [expandedSections, setExpandedSections] = useState<string[]>(
sections.filter(s => !s.isAdvanced).map(s => s.id)
);
const toggleSection = (id: string) => {
setExpandedSections(prev =>
prev.includes(id)
? prev.filter(sectionId => sectionId !== id)
: [...prev, id]
);
};
return (
<div className="space-y-4">
{sections.map((section) => {
const isExpanded = expandedSections.includes(section.id);
return (
<div
key={section.id}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
onClick={() => toggleSection(section.id)}
className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100"
>
<div className="text-left">
<h3 className="font-medium">{section.title}</h3>
{section.description && (
<p className="text-sm text-gray-600">{section.description}</p>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronDown className="w-5 h-5 text-gray-500" />
)}
</button>
{isExpanded && (
<div className="p-4 bg-white">
{section.fields}
</div>
)}
</div>
);
})}
</div>
);
}
// Usage
export function ProjectSettings() {
return (
<form>
<ProgressiveForm
sections={[
{
id: 'basic',
title: 'Basic Information',
description: 'Essential project details',
fields: (
<div className="space-y-4">
<input type="text" placeholder="Project name" className="w-full p-2 border rounded" />
<textarea placeholder="Description" className="w-full p-2 border rounded" />
</div>
),
},
{
id: 'team',
title: 'Team & Permissions',
description: 'Manage who can access this project',
fields: (
<div className="space-y-4">
<input type="email" placeholder="Invite by email" className="w-full p-2 border rounded" />
{/* Team member list */}
</div>
),
},
{
id: 'advanced',
title: 'Advanced Settings',
description: 'Additional configuration options',
isAdvanced: true,
fields: (
<div className="space-y-4">
<label className="flex items-center gap-2">
<input type="checkbox" />
Enable API access
</label>
<input type="text" placeholder="Custom domain" className="w-full p-2 border rounded" />
</div>
),
},
]}
/>
</form>
);
}
A/B Testing Onboarding Patterns
// hooks/useOnboardingVariant.ts
import { useEffect, useState } from 'react';
type OnboardingVariant = 'control' | 'tour' | 'checklist' | 'tutorial';
export function useOnboardingVariant(): OnboardingVariant {
const [variant, setVariant] = useState<OnboardingVariant>('control');
useEffect(() => {
// Check if user already has a variant assigned
const savedVariant = localStorage.getItem('onboarding-variant');
if (savedVariant) {
setVariant(savedVariant as OnboardingVariant);
} else {
// Randomly assign variant
const variants: OnboardingVariant[] = ['control', 'tour', 'checklist', 'tutorial'];
const randomVariant = variants[Math.floor(Math.random() * variants.length)];
setVariant(randomVariant);
localStorage.setItem('onboarding-variant', randomVariant);
// Track assignment
analytics.track('Onboarding Variant Assigned', {
variant: randomVariant,
timestamp: new Date().toISOString(),
});
}
}, []);
return variant;
}
// Usage in app
export function Dashboard() {
const variant = useOnboardingVariant();
return (
<div>
{variant === 'tour' && <DashboardTour />}
{variant === 'checklist' && <OnboardingChecklist steps={[...]} />}
{variant === 'tutorial' && <InteractiveTutorial tasks={[...]} />}
{/* Control group gets no onboarding UI */}
{/* Rest of dashboard */}
</div>
);
}
Anti-Patterns
❌ Anti-Pattern 1: Forced Long Tours
// DON'T: Force users through 15-step tour before they can use the app
<Joyride
steps={fifteenSteps}
run={true}
continuous={true}
disableCloseOnEsc={true}
disableOverlayClose={true}
hideCloseButton={true}
/>
// DO: Make tours optional and skippable
<Joyride
steps={threeKeySteps}
run={userOptedIn}
showSkipButton={true}
/>
❌ Anti-Pattern 2: Generic Empty States
// DON'T: Lazy, unhelpful empty state
<div>No data</div>
// DO: Actionable, contextual empty state
<EmptyState
title="No projects yet"
description="Create your first project to start collaborating"
action={{ label: "Create Project", onClick: createProject }}
/>
❌ Anti-Pattern 3: Tooltip Overload
// DON'T: Tooltip on every single element
<Tooltip content="This is a button"><button>Click</button></Tooltip>
<Tooltip content="This is text"><p>Some text</p></Tooltip>
<Tooltip content="This is an image"><img /></Tooltip>
// DO: Only on non-obvious UI elements
<Tooltip content="Advanced filters"><IconButton icon={Settings} /></Tooltip>
Complete Example: SaaS App Onboarding Flow
// app/dashboard/page.tsx
import { useState, useEffect } from 'react';
import { useUser } from '@/hooks/useUser';
import { DashboardTour } from '@/components/DashboardTour';
import { OnboardingChecklist } from '@/components/OnboardingChecklist';
import { EmptyState } from '@/components/EmptyState';
export default function Dashboard() {
const { user } = useUser();
const [showTour, setShowTour] = useState(false);
const [projects, setProjects] = useState([]);
// Show tour for new users
useEffect(() => {
if (user && !user.hasCompletedOnboarding) {
setShowTour(true);
}
}, [user]);
const onboardingSteps = [
{
id: 'profile',
title: 'Complete your profile',
description: 'Add your name and photo',
completed: !!user?.name && !!user?.avatar,
},
{
id: 'project',
title: 'Create your first project',
description: 'Get started with a new project',
completed: projects.length > 0,
},
{
id: 'invite',
title: 'Invite a team member',
description: 'Collaborate with others',
completed: user?.teamSize > 1,
},
];
const allStepsCompleted = onboardingSteps.every(s => s.completed);
return (
<div className="p-8">
{showTour && <DashboardTour />}
{!allStepsCompleted && (
<OnboardingChecklist steps={onboardingSteps} />
)}
<div className="mb-8">
<h1 className="text-3xl font-bold">Projects</h1>
</div>
{projects.length === 0 ? (
<EmptyState
icon={FolderPlus}
title="No projects yet"
description="Create your first project to start organizing your work and collaborating with your team."
action={{
label: "Create Project",
onClick: () => router.push('/projects/new'),
}}
secondaryAction={{
label: "Watch Demo",
onClick: () => setShowTour(true),
}}
/>
) : (
<div className="grid grid-cols-3 gap-4">
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
);
}
Recommended Approach for 6-Day Sprints
Day 1: Empty states for all major sections (2-4 hours) Day 2: Tooltips for icon buttons and new features (2 hours) Day 3: Onboarding checklist for first-time users (4 hours) Day 4-5: Optional: Product tour for complex workflows (8 hours if needed) Day 6: Polish and A/B test setup
Priority Order:
- Empty states (highest ROI, lowest effort)
- Contextual tooltips (quick wins)
- Onboarding checklist (guides without blocking)
- Product tour (only if complex multi-step workflows)
- Interactive tutorials (only for power features)
Key Metrics to Track:
- Time to first value action
- Onboarding completion rate
- Feature adoption rate
- User drop-off points
- Support ticket reduction
Focus on getting users to their "aha moment" within the first 2 minutes. Everything else is secondary.