Claude Code Plugins

Community-maintained marketplace

Feedback

User Onboarding Patterns

@chriscarterux/chris-claude-stack
1
0

Comprehensive guide to UX onboarding patterns including progressive disclosure, tooltips, walkthroughs, empty states, first-run experiences, and interactive tutorials with React implementations

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

  1. Empty states (highest ROI, lowest effort)
  2. Contextual tooltips (quick wins)
  3. Onboarding checklist (guides without blocking)
  4. Product tour (only if complex multi-step workflows)
  5. 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.