Onboarding Flow in React: Multi-Step, Spotlight and Tooltip Tours
Build multi-step onboarding flows in React with spotlight overlays, tooltip tours, and state machines. Patterns, code, and library picks for 2026.
Why Onboarding Is Harder Than It Looks
You ship a feature. It's genuinely good. Users open the app, stare for four seconds, and close the tab. That's the onboarding problem in a nutshell — the gap between 'this is intuitive once you know it' and 'I have no idea what to do first' is enormous, and most teams underestimate it until the analytics land.
Honestly, the mistake I see most often is treating onboarding as a one-time modal. You get a 'Welcome!' card, three bullet points of marketing copy, and a 'Get Started' button that drops you directly into a blank dashboard. That works for zero users. Real onboarding is contextual — it meets users where they are, highlights the one thing they need to do *right now*, and gets out of the way fast.
In React, onboarding typically means one of three patterns: a multi-step wizard (a full-page or modal flow that collects info before the main experience), a tooltip tour (sequential overlays that point at existing UI elements), or a spotlight overlay (a darkened page with a cutout highlighting a specific component). Each solves a different problem. Knowing which to pick before you write a line of code saves you a painful refactor later.
That said, these patterns aren't mutually exclusive. A lot of production apps combine them — a short wizard to capture role/team info, then a spotlight tour of the dashboard. The architecture decisions that let you compose them cleanly are what we're getting into here.
Multi-Step Wizard: State Management That Doesn't Hurt
A multi-step wizard is just a controlled flow of steps with a shared state object. The naive implementation — a pile of useState booleans and a currentStep counter — works until step 4 when you realize you need to validate step 2 from step 5, or skip step 3 based on a choice made in step 1. Then it gets ugly fast.
In practice, the cleanest approach in 2026 is a small state machine. You don't need XState for this (though XState 5 is excellent). A plain useReducer with an explicit step graph covers 90% of cases:
type Step = 'role' | 'team' | 'integrations' | 'done';
const STEPS: Record<Step, { next: Step | null; prev: Step | null }> = {
role: { next: 'team', prev: null },
team: { next: 'integrations', prev: 'role' },
integrations: { next: 'done', prev: 'team' },
done: { next: null, prev: 'integrations' },
};
type State = { step: Step; data: Record<string, unknown> };
type Action =
| { type: 'NEXT'; payload?: Record<string, unknown> }
| { type: 'BACK' }
| { type: 'JUMP'; step: Step };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'NEXT': {
const nextStep = STEPS[state.step].next;
if (!nextStep) return state;
return { step: nextStep, data: { ...state.data, ...action.payload } };
}
case 'BACK': {
const prevStep = STEPS[state.step].prev;
if (!prevStep) return state;
return { ...state, step: prevStep };
}
case 'JUMP':
return { ...state, step: action.step };
default:
return state;
}
}Worth noting: storing your collected data inside the reducer — not in local component state — means any step component can read previously entered values without prop drilling. Expose this via context and your wizard becomes trivially composable.
One more thing — progress indication. Users abandon flows without it. A dead-simple step bar with width: ${(currentIndex / totalSteps) * 100}% on a 4px tall track does the job. Don't overthink the animation; a CSS transition: width 300ms ease is enough. The visual feedback matters more than the aesthetics.
Tooltip Tours: Positioning Without Tearing Your Hair Out
Tooltip tours are the 'let me show you around' pattern — you highlight an existing UI element and describe it, then move to the next one. The core technical challenge isn't the copy or the overlay; it's positioning. You need to compute where to render the tooltip relative to the target element, accounting for scroll position, viewport edges, and elements that might be inside scroll containers.
The Floating UI library (formerly Popper.js, rewritten) handles this correctly as of v2.x and it's what I'd reach for today. It computes placement, detects overflow, and falls back gracefully:
import { useFloating, offset, flip, shift, arrow } from '@floating-ui/react';
import { useRef } from 'react';
function TourTooltip({ target, content, onNext, onSkip }) {
const arrowRef = useRef(null);
const { refs, floatingStyles, middlewareData } = useFloating({
elements: { reference: target },
placement: 'bottom',
middleware: [
offset(12),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
});
const arrowX = middlewareData.arrow?.x ?? '';
const arrowY = middlewareData.arrow?.y ?? '';
return (
<div ref={refs.setFloating} style={floatingStyles} className="tour-tooltip">
<div
ref={arrowRef}
className="tour-arrow"
style={{ left: arrowX, top: arrowY, position: 'absolute' }}
/>
<p>{content}</p>
<div className="tour-actions">
<button onClick={onSkip}>Skip</button>
<button onClick={onNext}>Next →</button>
</div>
</div>
);
}The offset(12) keeps 12px of breathing room between the arrow and the target — that specific value matters, tighter than 8px and it feels cramped on mobile. The flip() middleware automatically moves the tooltip above when there's no room below, which saves you a ton of conditional logic.
For the highlight ring around the target element, resist the urge to clone the DOM node. Instead, use getBoundingClientRect() on the target ref and render an absolutely-positioned highlight div at those coordinates in a portal at the root of your app. Update it on scroll and resize with a ResizeObserver. Quick aside: if your target elements can be inside scroll containers, you need to observe scroll on those containers too — not just window.
Spotlight Overlay: The Darkened Cutout Pattern
Spotlight is the visually dramatic option — you darken the whole page and punch a hole over the element you want to highlight. It's great for 'first time you open this screen' moments where you want zero ambiguity about what to look at. You'd use this for a single critical call-to-action, not a 10-step tour.
The cleanest implementation uses a single SVG or clip-path approach rendered in a fixed-position overlay. The SVG approach is the most compatible — you draw a full-viewport rect, then subtract the highlight area using a <mask> or a <clipPath> with a <rect> cutout:
function SpotlightOverlay({ targetRect, borderRadius = 8, padding = 8, children }) {
const { x, y, width, height } = targetRect;
const px = x - padding;
const py = y - padding;
const pw = width + padding * 2;
const ph = height + padding * 2;
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 9999,
pointerEvents: 'none',
}}
>
<svg
width="100%" height="100%"
style={{ position: 'absolute', inset: 0 }}
>
<defs>
<mask id="spotlight-mask">
<rect width="100%" height="100%" fill="white" />
<rect
x={px} y={py} width={pw} height={ph}
rx={borderRadius} ry={borderRadius}
fill="black"
/>
</mask>
</defs>
<rect
width="100%" height="100%"
fill="rgba(0,0,0,0.6)"
mask="url(#spotlight-mask)"
/>
</svg>
<div style={{ pointerEvents: 'auto' }}>{children}</div>
</div>
);
}Honestly, the SVG mask approach beats the box-shadow: 0 0 0 9999px rgba(0,0,0,0.6) trick you'll find in older tutorials. The shadow trick breaks on rounded cutouts, doesn't handle multiple spotlights, and has clipping issues in some Safari versions. The SVG is two dozen lines and works everywhere.
For the animation, a CSS transition on opacity from 0 to 1 over 200ms is all you need on the overlay itself. Animate the cutout rect dimensions if you want to give the spotlight a 'zoom in' feel — changing x, y, width, height on the SVG rect with a CSS transition doesn't work directly, but you can animate a wrapping <g transform> or just use Framer Motion's animate prop on the rect. Look, keep it simple unless motion is core to your brand — users are here for the content, not the animation.
Libraries Worth Knowing in 2026
You don't always need to build from scratch. Three libraries have genuine production pedigree for this use case. Shepherd.js has been around since 2014, it's framework-agnostic, and the React wrapper is solid. If you need something that works outside of React too (for a marketing site or a server-rendered page), Shepherd's a safe pick.
React Joyride is the most popular React-specific option. Version 2.x added floating-ui support and the API is clean. It handles scroll-into-view automatically, which saves a surprisingly large amount of code. The main downside is that the default styles are very plain and you'll want to replace them entirely — which it supports, just expect to spend 30 minutes on it.
Intro.js is older and heavier, but it has one genuinely useful feature: it can drive tours on static HTML without any JS framework, which makes it nice for hybrid apps. In a pure React context you'd probably choose one of the above instead.
Worth noting: all three libraries solve positioning and step management for you, but none of them solve the *content* or *state persistence* problems. Where are you storing whether the user has completed this tour? Most teams throw it in a user_preferences table or localStorage. If it's localStorage, add a version key so you can force-replay the tour after a major UI change — something like tour_v3_completed instead of just tour_completed.
Persisting State and Skipping for Power Users
The most common onboarding bug I've seen: the tour replays on every page refresh because nobody wired up persistence. Pick a storage strategy early and stick to it. For anonymous/pre-auth flows, localStorage is fine. Post-auth, sync to your backend — users switching devices expect their onboarding state to follow them.
A clean hook that abstracts this away:
function useOnboardingState(tourKey: string) {
const [completed, setCompleted] = React.useState<boolean>(() => {
return localStorage.getItem(tourKey) === 'true';
});
const complete = React.useCallback(() => {
localStorage.setItem(tourKey, 'true');
setCompleted(true);
// fire your analytics event here
}, [tourKey]);
const reset = React.useCallback(() => {
localStorage.removeItem(tourKey);
setCompleted(false);
}, [tourKey]);
return { completed, complete, reset };
}
// Usage:
const { completed, complete } = useOnboardingState('dashboard_tour_v2');
if (completed) return null;The reset function is non-negotiable for QA. Add a keyboard shortcut or a hidden button in dev builds so your team can replay the tour without clearing storage manually. Something like a ?reset_tour=1 query param handler during development.
One more thing — the skip button. Always include it, always put it in the top right, always make it obvious. A hidden skip or a tiny grey link is an anti-pattern that erodes user trust. Users who skip are *not* failing — they're power users who'll explore on their own. Let them. And for your component library needs, browse components at Empire UI — there are ready-made card, modal, and button primitives that slot cleanly into wizard UIs, saving you from building basic UI chrome from scratch.
If your app has strong visual identity needs — say you're building on a glassmorphism or cyberpunk design system — make sure your onboarding components inherit that identity. Nothing says 'we threw this together last minute' like a plain white tooltip card in the middle of a dark themed app.
Accessibility and Testing You Actually Need
Onboarding flows have terrible accessibility records. Focus management is almost always missing — when a tooltip appears, keyboard focus should move to it. When it closes, focus should return to the trigger or continue naturally through the page. Not doing this makes your tour completely unusable for keyboard and screen reader users.
Minimum viable a11y checklist: trap focus inside each step's tooltip or modal panel, use role="dialog" with an aria-label on the tooltip container, call .focus() on the first focusable element inside the tooltip when it mounts, and return focus to the previously focused element on close. For spotlight overlays, ensure the underlying page elements have aria-hidden="true" while the overlay is active so screen readers don't read behind it.
For testing, don't bother writing unit tests for the positioning math — that's Floating UI's job and they test it. What you *do* need: integration tests that verify the sequence advances correctly, that 'skip' marks the tour complete, that completed state prevents the tour from rendering on re-mount, and that step data accumulates correctly across wizard steps. Playwright or Cypress handle all of this cleanly. A spec that walks through the full tour in under 30 lines of test code is totally achievable.
Quick aside: if you're using Vitest, mock getBoundingClientRect in your tour component tests — jsdom returns zeros for everything, which will make your position-dependent logic fail. A simple vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ x: 100, y: 200, width: 300, height: 48, ... }) in your test setup covers it.
FAQ
A tooltip tour points at existing elements in sequence without blocking the rest of the UI. A spotlight overlay darkens the page and focuses attention on a single element — more disruptive, better for first-time critical actions.
Use a library if you need a standard tooltip tour up fast. Roll your own if you need unusual step logic, strong visual customization, or deep integration with your app's state management — the core isn't that much code.
Store a versioned completion flag in localStorage or your user preferences table — something like tour_v2_completed. Version it so you can force-replay after major UI changes.
Use Floating UI — it handles scroll container detection out of the box. If you're computing positions manually, listen to scroll events on the container element, not just window.