Guided Tours Are a State Machine Wearing a Spotlight
Jul 2026 · 9 min read
Standing up UMA as a brand-new greenfield app alongside the existing MMSD Portal meant a second surface where users would land unfamiliar and need onboarding — and I didn't want to hand-wire tour state a second time. The temptation with a guided tour is to think of it as a UI feature: a spotlight, an overlay, a popover with Next/Back buttons. That part is real, and driver.js already does it well. The part that actually determines whether a tour library is pleasant to use or a source of bugs is underneath the UI entirely: a guided tour is a state machine — current step, can the user advance, what happens on entering and leaving a step — and most of the hard problems live in that state machine, not in the popover.
The problem driver.js doesn't solve: leaving the page
A single-page tour is easy. A tour that spans a route change is where naive implementations break, because the element the next step needs to highlight doesn't exist yet — it hasn't mounted, because the navigation the previous step triggered hasn't finished.
{
target: "#create-project",
title: "Create a project",
content: "Start here once you're on the dashboard.",
beforeNext: () => router.push("/dashboard"),
waitForTarget: 5000, // wait up to 5s for #create-project to mount
}beforeNext lets a step trigger its own navigation rather than assuming the target is already on screen, and waitForTargetpolls for the next step's target after that navigation, with a timeout so a broken selector fails the tour gracefully instead of leaving a user staring at a spotlight that never arrives. This one mechanism is what turns "works on one page" into "works across an entire onboarding flow" — without it, a guided tour is really just a guided section of one page.
Not every step should wait for a click
Some steps are informational and should auto-advance after a delay. Some should require the user to actually perform the action being demonstrated — click the real button, not a "Next" button standing in for it — via advanceOn. Some need a hard gate: don't let the user proceed until a form field actually has a value, via canAdvance. Treating all three as the same "click Next to continue" interaction is what makes tours feel like a lecture instead of a walkthrough; distinguishing them at the schema level is what makes a tour feel like it's actually watching what the user does.
Persistence is three separate decisions, not one flag
"Has the user seen this tour" sounds like a boolean. In practice it's three separate, sometimes conflicting decisions: should a user resume mid-tour after a page reload (persistProgress), should a tour that meaningfully changed re-show itself even to users who completed the old version (a version bump), and should a tour that keeps getting dismissed eventually stop showing at all (a showCountcap). Collapsing these into one "seen it" flag either re-annoys users with a tour that barely changed, or permanently suppresses a tour that genuinely needs to re-introduce itself after a real redesign. Keeping them as separate, composable options means each app can decide its own tolerance for re-showing, instead of the library making that call for everyone.
A registry, so the trigger doesn't need to know the tour
A help menu that can re-launch "the pricing tour" and a component three levels deep in the tree that defines what that tour actually contains shouldn't need to be wired together explicitly. Tours register globally by ID and start from anywhere:
// registered once, wherever the tour is defined
registerTour({ id: "pricing-tour", steps: [...] });
// started from anywhere else, no prop drilling
<button onClick={() => startTour("pricing-tour")}>Show me around pricing</button>This is the same "decouple the thing that triggers an action from the thing that defines it" principle that shows up everywhere in UI architecture, applied specifically to tours instead of being solved fresh, differently, in every app that adds one.
Sequences: because onboarding is rarely one flow
A real first-run experience is usually several logical chunks — set up your workspace, then create your first project, then invite your team — not one monolithic fifteen-step tour. useTourSequence chains named tours so completing one starts the next, which matches how onboarding actually gets designed: as discrete milestones, not one script.
Analytics as an emitted event, not an owned pipeline
The library emits lifecycle events — step viewed, tour completed, tour abandoned at step N — through adapters for PostHog, Segment, Mixpanel, and Amplitude, rather than deciding for a consuming app which analytics tool it should be using. A tour library that insists on owning its own analytics pipeline is a tour library that's harder to adopt into an app that already has one; emitting events and letting the host app route them is the same registry-not-ownership philosophy applied to a different concern.
The payoff: the second app was fast because the first one wasn't special-cased
The same engine — as a customized fork — now runs guided tours in both the Portal, a mature existing app, and UMA, built from scratch. The tour semantics (cross-route waiting, action gates, persistence rules) are identical in both. What differs is only the actual tour content each app defines. That's the concrete payoff of treating "guided tour state machine" as a concern separable from any one app's UI: UMA's onboarding took a fraction of the time the Portal's original tour work did, because the hard part — the state machine — had already been solved once, generically, instead of being solved again specifically for a second app.