EmpireUI
Get Pro
← Blog8 min read#saas#onboarding#ui design

SaaS Onboarding UI: Checklists, Progress Steps and Empty States

Build SaaS onboarding UI that actually converts — checklists, multi-step flows and empty states in React, with real code and design patterns that don't frustrate users.

Multi-step SaaS onboarding UI checklist and progress steps dashboard

Why Onboarding UI Makes or Breaks Activation

You can spend months building a product that solves a real problem, ship it, and still see users bounce in the first 10 minutes. Not because they don't want what you're selling — but because they open the dashboard and see a wall of empty space with no idea what to do next. That's a design failure, not a product failure.

Onboarding UI is the bridge between signup and that first 'aha moment'. It's not glamorous work. Nobody tweets about a great checklist. But activation rates — the percentage of new signups who actually complete a meaningful action — are almost entirely driven by how well you guide users through those first few steps.

Honestly, most SaaS apps treat onboarding as an afterthought. They slap a modal tooltip tour on top of a finished product and call it done. Users click through it in 8 seconds without reading anything, close it, and then stare at the same empty dashboard they would have seen anyway. The tour solved nothing.

The patterns that actually work: a persistent checklist that lets users pick their own path, a clear progress indicator so they know how far they are from 'done', and thoughtfully designed empty states that show what the UI will look like once it's populated. We'll build all three.

The Onboarding Checklist Component

A checklist is the workhorse of SaaS onboarding. It works because it gives users a sense of agency — they can see what's left, complete things in any order, and get a dopamine hit each time something ticks off. Intercom famously attributed a lot of their early activation growth to a simple getting-started checklist. That was 2014, and the pattern has barely changed because it works.

The key decisions: how many items, whether to auto-expand completed steps, and whether to dismiss the whole checklist once everything's done. Keep items to 5 or fewer. Past that, users start treating it as a to-do list they'll 'get to later' instead of something to finish right now. Each item should be completable in under 2 minutes.

Worth noting: the checklist should be persistent in the sidebar or dashboard header — not a one-time modal. Users need to be able to come back to it after they close a browser tab or get interrupted. Here's a clean starting point in React:

type ChecklistItem = {
  id: string;
  label: string;
  description: string;
  completed: boolean;
  action?: () => void;
};

function OnboardingChecklist({ items }: { items: ChecklistItem[] }) {
  const completed = items.filter((i) => i.completed).length;
  const progress = Math.round((completed / items.length) * 100);

  return (
    <div className="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
      <div className="mb-4 flex items-center justify-between">
        <h2 className="text-sm font-semibold text-neutral-800">
          Getting started — {completed}/{items.length} done
        </h2>
        <span className="text-xs text-neutral-500">{progress}%</span>
      </div>
      <div className="mb-4 h-1.5 w-full overflow-hidden rounded-full bg-neutral-100">
        <div
          className="h-full rounded-full bg-indigo-500 transition-all duration-500"
          style={{ width: `${progress}%` }}
        />
      </div>
      <ul className="space-y-2">
        {items.map((item) => (
          <li
            key={item.id}
            className={`flex items-start gap-3 rounded-lg p-3 transition-colors ${
              item.completed ? 'bg-neutral-50' : 'hover:bg-neutral-50'
            }`}
          >
            <div
              className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
                item.completed
                  ? 'border-indigo-500 bg-indigo-500'
                  : 'border-neutral-300'
              }`}
            >
              {item.completed && (
                <svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 12 12">
                  <path
                    d="M2 6l3 3 5-5"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              )}
            </div>
            <div>
              <p
                className={`text-sm font-medium ${
                  item.completed ? 'text-neutral-400 line-through' : 'text-neutral-800'
                }`}
              >
                {item.label}
              </p>
              {!item.completed && (
                <p className="mt-0.5 text-xs text-neutral-500">{item.description}</p>
              )}
            </div>
            {!item.completed && item.action && (
              <button
                onClick={item.action}
                className="ml-auto shrink-0 rounded-md bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-600 hover:bg-indigo-100"
              >
                Start
              </button>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

One more thing — wire the completed state to real events in your app, not just button clicks inside the checklist itself. If 'Connect your first integration' auto-checks when the user actually saves an integration elsewhere in the app, that's satisfying. If they have to manually come back and tick a box, it feels like busywork.

Multi-Step Progress Indicators

Progress steps are different from checklists. They're for linear flows — account setup wizards, workspace configuration, billing onboarding — where you need users to go through steps in a specific order. The visual language needs to communicate: where you are, where you've been, and what's coming.

In practice, a horizontal stepper works well for 3–5 steps on desktop. Go vertical if you have more steps or if you're building a mobile-first product — horizontal steppers collapse badly on 375px screens. The step indicator itself should be 32px or 40px in diameter, large enough to be a clear tap target but not so large it dominates the layout.

type Step = { label: string; description?: string };

type StepperProps = {
  steps: Step[];
  currentStep: number; // 0-indexed
};

function Stepper({ steps, currentStep }: StepperProps) {
  return (
    <nav aria-label="Progress">
      <ol className="flex items-center">
        {steps.map((step, idx) => {
          const status =
            idx < currentStep
              ? 'complete'
              : idx === currentStep
              ? 'current'
              : 'upcoming';

          return (
            <li
              key={step.label}
              className={`flex items-center ${
                idx < steps.length - 1 ? 'flex-1' : ''
              }`}
            >
              <div className="flex flex-col items-center">
                <div
                  className={`flex h-9 w-9 items-center justify-center rounded-full border-2 text-sm font-semibold transition-all ${
                    status === 'complete'
                      ? 'border-indigo-600 bg-indigo-600 text-white'
                      : status === 'current'
                      ? 'border-indigo-600 bg-white text-indigo-600'
                      : 'border-neutral-300 bg-white text-neutral-400'
                  }`}
                  aria-current={status === 'current' ? 'step' : undefined}
                >
                  {status === 'complete' ? (
                    <svg className="h-4 w-4" fill="none" viewBox="0 0 16 16">
                      <path
                        d="M3 8l3.5 3.5L13 4"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  ) : (
                    <span>{idx + 1}</span>
                  )}
                </div>
                <span
                  className={`mt-1.5 text-xs font-medium ${
                    status === 'current' ? 'text-indigo-600' : 'text-neutral-500'
                  }`}
                >
                  {step.label}
                </span>
              </div>
              {idx < steps.length - 1 && (
                <div
                  className={`mx-2 h-0.5 flex-1 ${
                    idx < currentStep ? 'bg-indigo-600' : 'bg-neutral-200'
                  }`}
                />
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

That connector line between steps is easy to overlook but it matters a lot visually — it communicates completion state at a glance without users having to read anything. Animate the color transition with transition-colors duration-300 and users will genuinely feel the progress happening.

Quick aside: always add aria-current="step" to the active step and wrap the whole thing in a <nav> with a label. Screen readers need this. It's 10 seconds of extra work and makes a real difference for accessibility.

Empty States That Actually Help

Empty states are the most underinvested part of any SaaS UI. They appear when a user has zero data — no projects, no team members, no integrations — and they're your last chance to tell someone what to do before they give up and close the tab. Most apps show a grey box with 'No items found' and call it done. Don't be that app.

A good empty state has three parts: an illustration or icon that communicates context, a headline that says what's missing in plain language, and a single primary CTA that starts the action right there. No secondary buttons, no links to documentation, just one obvious next step. What's the one thing you want them to do right now?

type EmptyStateProps = {
  icon: React.ReactNode;
  headline: string;
  subtext: string;
  action: {
    label: string;
    onClick: () => void;
  };
};

function EmptyState({ icon, headline, subtext, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-8 py-16 text-center">
      <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 text-neutral-400">
        {icon}
      </div>
      <h3 className="mb-1.5 text-base font-semibold text-neutral-800">
        {headline}
      </h3>
      <p className="mb-6 max-w-xs text-sm text-neutral-500">{subtext}</p>
      <button
        onClick={action.onClick}
        className="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        {action.label}
      </button>
    </div>
  );
}

That dashed border is a deliberate choice — it visually signals 'this space is waiting to be filled', which is subtly different from a solid container. It's a small detail but it changes how users read the state. Pair this with a simple SVG illustration (not stock photography) and it goes from 'error state vibes' to 'friendly prompt to get started'.

In practice, you'll need multiple empty states for different contexts — empty project list, empty team, empty activity feed. Don't use the same generic component for all of them. Tailor the headline and CTA copy to be specific. 'You haven't created any projects yet' is worse than 'Start your first project — it takes 30 seconds'.

Connecting the Pieces: State Management and Persistence

The checklist and progress tracker are only useful if they reflect real app state. If a user completes step 2 on Monday and comes back on Wednesday, the checklist should remember. That means you need persistence — either in your backend or at minimum in localStorage with a user-keyed namespace.

For most SaaS apps, storing onboarding state in your database is the right call. A simple onboarding_progress JSON column on your users table works fine. You POST to it whenever a step completes, fetch it on load, and that's basically it. The complex stuff is deciding which app events trigger which step completions — that logic should live server-side, not in the client.

// Minimal onboarding state shape
type OnboardingState = {
  completedSteps: string[];
  skippedAt?: string; // ISO timestamp if user dismissed
  completedAt?: string; // ISO timestamp when all done
};

// Event-driven step completion (server side)
async function markStepComplete(
  userId: string,
  stepId: string
): Promise<void> {
  const current = await db.user.findUnique({
    where: { id: userId },
    select: { onboardingProgress: true },
  });

  const state: OnboardingState = current?.onboardingProgress ?? {
    completedSteps: [],
  };

  if (!state.completedSteps.includes(stepId)) {
    state.completedSteps.push(stepId);
  }

  const allStepIds = ['connect-integration', 'invite-teammate', 'create-first-item', 'customize-profile'];
  if (allStepIds.every((id) => state.completedSteps.includes(id))) {
    state.completedAt = new Date().toISOString();
  }

  await db.user.update({
    where: { id: userId },
    data: { onboardingProgress: state },
  });
}

That said, you don't always need a full database write. Low-stakes apps can get by with localStorage as a fallback for logged-in state while the server call is in-flight. Just make sure you hydrate from the server on initial page load so the checklist doesn't flicker between 'nothing done' and the real state.

One pattern worth stealing from products like Linear and Notion: auto-dismiss the checklist once all items are complete, wait 1.5 seconds, then replace it with a small confetti burst and a 'You're all set!' banner. It's a tiny moment of delight that signals the transition from 'new user' to 'actual user'. You can pull motion patterns from Empire UI's glassmorphism components or aurora style for the banner treatment.

Polish Details That Separate Good from Great

The mechanics above will get you 80% of the way. The last 20% is the difference between onboarding that users don't hate and onboarding they actually remember. A few specific things that move the needle.

Animate the checklist item when it completes. A simple scale(1) -> scale(1.05) -> scale(1) bounce over 200ms on the checkmark circle is enough. Don't go crazy with Framer Motion here — you want acknowledgment, not a circus. The user's attention should move to the next item, not stay stuck on the animation. Check the gradient generator if you want a subtle gradient treatment on the completed state background.

Keep the checklist visible but out of the critical path. It should live in a sidebar widget or a collapsible header bar, not in the center of the main content area. Users need to be able to get to their actual work at any point — the checklist is a guide, not a gate. Gating access behind onboarding steps is one of the fastest ways to generate angry support tickets.

Quick aside: add a 'Skip for now' or 'Dismiss' option to every onboarding UI element. Some users — especially those who've used similar tools before — find the checklist patronizing. Give them an escape hatch and they'll actually feel better about your product, not worse. Track skips in your analytics alongside completions.

Look, the 40px height on each checklist row, the 8px gap between items, the dashed-border empty state — none of this is revolutionary design. It's just solid, considered execution of patterns that have existed for a decade. The visual language you choose can absolutely be more expressive. Browse the Empire UI component library and layer in glassmorphism, aurora gradients, or neobrutalism styling on top of these functional patterns — the structure stays the same, the personality changes.

Testing and Measuring Onboarding Flow Performance

You built the checklist. You shipped the empty states. Now how do you know if any of it works? You need two things: funnel analytics and qualitative session recordings. Neither one alone is enough.

Funnel analytics tell you where users drop off. Instrument each checklist step completion as an event — onboarding_step_completed with a step_id property. Track time-to-complete per step. If step 3 takes 4x longer than steps 1 and 2, something in that step is confusing or too much friction. That data tells you where to look; session recordings tell you why.

// PostHog / Mixpanel-style event tracking
function trackStepComplete(stepId: string, timeSpentMs: number) {
  analytics.track('onboarding_step_completed', {
    step_id: stepId,
    time_spent_ms: timeSpentMs,
    timestamp: new Date().toISOString(),
  });
}

// Call this when a step flips to complete
useEffect(() => {
  if (step.completed && !prevCompletedRef.current) {
    const elapsed = Date.now() - stepStartTime;
    trackStepComplete(step.id, elapsed);
  }
  prevCompletedRef.current = step.completed;
}, [step.completed]);

Worth noting: activation rate is your North Star metric here, not completion rate. Completion rate (how many users finish every checklist item) sounds like the thing to optimize, but activation rate (how many users hit their first meaningful value moment) is what actually correlates with retention. A user who completed 2 of 5 steps and then became a power user is a success story. A user who completed all 5 steps and churned after a week is not.

Run A/B tests on your empty states and checklist copy before you run them on layout or visual design. Copy changes are cheap to ship and often have a bigger impact than visual ones. Test the CTA button label ('Create project' vs 'Start your first project' vs 'Let's go'), test the empty state headline, test whether showing estimated time per step increases completion. Small copy tweaks from 2025 experiments at companies like Loom and Notion moved activation by 12-18% — that's significant for minimal engineering effort.

FAQ

Should onboarding checklists be in a modal or persistent in the dashboard?

Persistent in the dashboard, every time. Modals block work and users close them before reading. A sidebar widget or collapsible header bar keeps the checklist accessible without interrupting the flow — users can check off items as they naturally explore the product.

How many steps should a SaaS onboarding checklist have?

Five or fewer. Past that, users treat it as a backlog item rather than something to finish now. Each step should be completable in under 2 minutes — if a step takes longer, break it into two steps or cut it entirely.

What's the difference between a checklist and a multi-step wizard for onboarding?

A checklist is non-linear — users pick their own order and can skip between items. A wizard is sequential — step 2 is locked until step 1 is done. Use checklists for general product setup, wizards for flows that have real dependencies between steps like billing configuration.

How do you handle empty states for users who have partial data vs truly zero data?

They're different problems. Zero data needs a strong CTA to create the first item. Partial data (like one project out of an expected many) needs different copy — something like 'Add more projects to see your dashboard come to life'. Detect both states and show contextually appropriate UI for each.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Glassmorphism Onboarding Steps: Frosted Progress and Feature SlidesGlassmorphism Onboarding UI: Multi-Step Wizard With Frosted StepsOnboarding Flow in React: Multi-Step, Spotlight and Tooltip ToursStepper Component in React: Multi-Step Forms and Onboarding