Design a product tour
An in-app product tour and onboarding checklist: welcome modals, step-by-step tips anchored to real UI, a spotlight that dims everything but one button, beacons, and a checklist that completes when people do the real thing. Drawing a bubble is easy; the hard parts are steps defined as data, anchoring to elements that load late or never appear, a spotlight that lets the right clicks through, progress that survives reloads and devices, rules so tours never stack or nag, measuring whether a tour helps at all, and doing it without hurting load speed or accessibility.
The question
We are a project management web app with about 2 million monthly users. New sign-ups often never create their first project. Design an in-app product tour system: a welcome message, guided steps that point at real parts of the UI, and an onboarding checklist that tracks progress.
Product managers want to create and change tours without a release. Tell me how a tour is defined, how a step finds the element it points at, how progress is saved, how you stop tours from annoying people, how you know a tour works, and how it stays fast and accessible.
Real-life examples: Appcues, Pendo, Userpilot and Intercom Product Tours, Open-source libraries: Shepherd.js, Driver.js, Intro.js, React Joyride, Linear's first-run onboarding, Notion's getting-started checklist page, Figma's in-file onboarding tips
Requirements: what kind of tour, for whom, built by whom
A product tour is a guided sequence of small messages shown inside the app itself, each one pointing at a part of the interface: "This is your inbox", "Click here to create a project". An onboarding checklist is its patient cousin: a short list of tasks, like "Invite a teammate", that stays available and ticks itself off as the user does each one. Products like Appcues, Pendo and Userpilot sell these as a service; libraries like Shepherd.js, Driver.js, Intro.js and React Joyride give you the parts to build one in-house.
It sounds like a tooltip with a Next button, but it isn't. A tour is a small program that runs on top of someone else's app: the app changes every week, loads its UI late, moves buttons around, and has its own dialogs, scroll panels and keyboard shortcuts. The tour must find its targets in that moving UI, survive page loads, and never get in the way of real work. Most of the design is about those three things.
Questions to ask first
Five questions change the design the most. Ask them before you draw anything:
- Who writes the tours? If only engineers do, tours can live in code next to the components. If product managers and designers must publish them without a release, tours become data (JSON on a server) and you need a visual editor. This one answer moves half the design.
- Which tour types? A one-off welcome modal is a small job. Steps anchored to live UI, a spotlight, and a checklist that tracks real actions across weeks are a system. Assume all four here.
- Single page or many? Tours that cross routes ("open Settings, now click Billing") need progress that survives navigation and reloads, which means state outside the component tree.
- Which devices? Desktop web only, or also phones? On a 375 px wide screen a tooltip next to a button has nowhere to go; mobile tours tend to be bottom sheets or full-screen cards.
- How do we know it works? If the goal is "more users create a first project in week one", you need step funnels and a holdout group from day one, not as an afterthought.
Functional requirements
- Show four kinds of guidance: a welcome modal, step tooltips anchored to UI elements, a spotlight that dims the page except the target, and hotspots (small pulsing dots, also called beacons) that open a tip when clicked.
- A checklist of 4 to 7 tasks that ticks items when the user does the real action, and persists across sessions and devices.
- Tours defined as data, published by non-engineers through a no-code editor, with versioning so editing a live tour doesn't break people halfway through it.
- Steps can wait for a user action ("click New project to continue") and can span several routes.
- Targeting: show a tour only to the right segment (role, plan, signup age, behaviour), at most once, with frequency caps and priority so two tours never stack.
- Measurement: per-step events (shown, next, back, dismissed, completed, target missing) and experiments against a no-tour holdout.
Non-functional requirements, with numbers
| Requirement | Target | Why it matters |
|---|---|---|
| Cost to users with no tour | 0 KB of tour engine downloaded; one small eligibility check | Over 90 percent of sessions are not eligible for any tour. They must pay nothing. |
| Engine size when needed | Under 25 KB gzipped, loaded lazily | It loads after the app is interactive, so it must not delay LCP or block input. |
| Step appears after target is ready | Under 100 ms | A tip that lags behind the UI it describes feels broken. |
| Wait for a late target | Up to 5 to 10 s, then skip or end | Data-driven UI can render seconds after navigation; never wait forever. |
| Progress saved | Within 1 s of each step change, survives reload and device switch | People sign up on a laptop and continue on another one the next day. |
| Accessibility | WCAG 2.2 AA: keyboard, screen reader, reduced motion | Tours are often the least accessible part of an app. |
| Scale | 2 million monthly users, about 50 active tours, 5 to 15 steps each | Tour definitions are small; the volume is in events: tens of millions a month. |
Out of scope, and where it lives instead
Tooltip positioning (flip, shift, collision with the viewport) is solved in the tooltip guide, so we reuse a positioning library and focus on tour logic. Focus traps and the top layer belong to the modal-dialog guide. Assigning users to experiment arms and holdouts belongs to the ab-testing-system guide, and evaluating targeting rules belongs to the feature-flag-sdk guide. We call those systems; we don't rebuild them.
Check your understanding · Exercise 1
Which answers from the interviewer change the architecture the most? Select all that apply.
What makes a product tour hard
Five properties of this problem drive every later decision. Name them out loud early; each one maps to a chapter.
- It runs on a moving host. The tour doesn't own the page. The button it points at may render 2 seconds after the route loads, sit inside a scrolling panel, live in an iframe, be one row of a virtualized list that isn't in the DOM yet, or be renamed in next week's release. A tour that assumes "the element is there" breaks constantly.
- It interrupts people who came to work. Nobody opens a project tool to take a tour. Every message costs attention, and people skip tours they didn't ask for. The design must decide when to stay quiet, which is most of the time.
- It lasts longer than a page. A checklist lives for days or weeks. A tour can cross three routes. Progress has to survive reloads, tabs, logouts and a second laptop, so it belongs on the server.
- Non-engineers author it. Tours are content that changes weekly, so they are data, not code. Data written by people who can't see the source must not be able to break the app, and edits must not strand people midway through a tour.
- It draws over everything. The spotlight and step must sit above sticky headers, open menus and modal dialogs, while letting the right clicks through and keeping keyboard and screen reader users oriented.
Two more properties are easy to forget. First, most sessions should pay nothing: on any given day the vast majority of users have no tour to see, so the engine must not ship in the main bundle. Second, a tour is a bet: it might help activation, do nothing, or annoy people into leaving. Without per-step funnels and a no-tour holdout, the team will never know which.
Think first
A tour step targets the CSS selector div.sidebar > ul > li:nth-child(3) > button. What will break it, and how often?
Almost any UI change: adding a sidebar item, wrapping the list in another div for styling, swapping a class name generated by CSS-in-JS, or rendering the item later. Teams that ship weekly will break selectors like this every few weeks, and nobody notices until the completion funnel drops to zero at that step.
The fix, covered in the next chapters, is a stable hook the app promises to keep, like data-tour="new-project", plus monitoring that alerts when a step's target goes missing.
Decision 1: which kind of guidance, and when
The naive plan is the classic product tour: on first login, a welcome modal, then eight tooltips in a row walking through every part of the screen. It's easy to build and easy to demo, and it tends to fail. People arrive with a goal, like "set up my team's board", and an eight-step lecture stands between them and that goal. Many click Skip on step one, and those who finish rarely remember step five when they need it two days later.
Nielsen Norman Group (NN/g) research on onboarding tutorials describes exactly this: tutorials shown out of context interrupt users, are often skipped, are quickly forgotten, and don't reliably improve task performance. They call that kind of help a push revelation (pushed at you when the system decides) and recommend pull revelations: help that appears in context, when the user is about to need it, or that the user chooses to open. That research shapes the menu of options below.
NN/g also measured it. In a study of 70 people using four mobile apps with skippable tutorials, one of them a deck-of-cards tutorial (a stack of intro screens you swipe through before using the app), people who read the tutorial did no better than people who skipped it: 91 percent task success against 94 percent, and no faster. Readers even rated the tasks as harder.
Vendor data points the same way. Chameleon's yearly benchmark of tours run on its platform reports that completion falls sharply once a tour goes past about five steps, and that tours users start themselves are completed far more often than tours that launch on their own. Treat vendor numbers as directional, since they come from one platform's customers, but the direction agrees with the research.
Decision Which form of guidance should carry new-user onboarding?
What we pick: Use the checklist as the backbone and contextual micro-tours as the muscle: the checklist is always available and tracks real actions, and each item can launch a 2 to 4 step tour at the moment the user picks it. Keep the welcome modal to one screen with one choice (start or later). Use hotspots for new-feature announcements to existing users, and the spotlight only on steps that require one specific click. A long linear tour on first login is the option to argue against.
| Guidance | Best for | Typical length | Interrupts? |
|---|---|---|---|
| Welcome modal | Greeting, one choice: start setup or later | 1 screen | Yes, once |
| Step tooltip | Pointing at where something lives | 2 to 4 steps | Partly: page stays usable |
| Spotlight step | One required click the user must not miss | 1 step inside a tour | Yes: page dimmed |
| Hotspot | New or optional features | 1 tip | No |
| Checklist | Activation tasks over days | 4 to 7 items | No |
Check your understanding · Exercise 2
Which situations fit the guidance chosen for them? Select all that apply.
Decision 2: steps as data, and how a step names its target
If tours are written in code (tour.addStep(...) calls in a component), every typo fix needs a release and every product manager needs an engineer. Since our requirement says non-engineers publish tours, a tour is data: a JSON document fetched at runtime and interpreted by a small engine. This is the config-driven UI idea from the config-driven-ui lesson: the engine knows how to render step types; the JSON says which steps, in what order, pointing where.
{
"id": "first-board",
"version": 4,
"trigger": { "route": "/projects/:id/board", "firstVisit": true },
"audience": { "segment": "new-editors", "maxSignupAgeDays": 14 },
"steps": [
{ "id": "columns", "kind": "tooltip",
"target": { "tour": "board-columns" }, "placement": "bottom",
"title": "Your workflow", "body": "Each column is a status." },
{ "id": "add-card", "kind": "spotlight",
"target": { "tour": "add-card" },
"title": "Add a task", "body": "Click Add card to continue.",
"advanceOn": { "click": "target" } },
{ "id": "drag", "kind": "tooltip",
"target": { "tour": "board-card", "nth": 0 },
"route": "/projects/:id/board",
"title": "Move it", "body": "Drag the card to Done.",
"advanceOn": { "event": "card.moved" },
"onMissingTarget": "skip", "waitMs": 8000 }
]
}
Each step has an id (stable across versions, used in analytics), a kind, a target, content, a placement hint, and optionally a route, an advanceOn rule (a click on the target, or an app event like card.moved), and what to do if the target never appears. The engine never runs code from the JSON: content is plain text or a tiny Markdown subset rendered as text nodes, so a compromised editor account can't inject a script.
Validate the document against a schema both on publish and when the client loads it, and drop a tour that fails rather than half-running it.
Decision How should a step identify the element it points at?
What we pick: Use data-tour ids as the contract, and let components that need special handling (virtualized lists, canvases) also register a reveal function in the registry. Allow a recorded selector only as a fallback the editor marks in red, with an alert when it stops matching. A CI check that every data-tour id used by a published tour still exists in the codebase turns silent breakage into a failed build.
Versioning: never change a tour under someone's feet
Say a product manager edits a live 5-step tour and deletes step 2. A user who was on step 3 reloads: their saved progress says stepIndex: 2, which now points at a different step. Saving progress by index is the bug.
Two rules fix it. First, published versions are immutable: an edit creates version 5, and version 4 stays readable. Second, progress stores the tour version and step id, not an index, so a user mid-tour finishes the version they started while new starts get the latest. When a fix must reach everyone (a broken target), the author marks the new version as "migrate", and the engine maps old step ids to new ones, skipping steps that no longer exist.
version and currentStepId, so user A finishes what they started while new users get the edit.The no-code editor
Tools like Appcues, Pendo and Userpilot ship a visual builder that runs on top of your live app, so an author clicks an element to attach a step. In-house, the same editor is the tour engine plus an authoring layer, loaded only for staff with an author role.
The authoring layer highlights elements with a data-tour hook on hover, lets the author type content and pick a placement, previews the tour as a chosen segment would see it, and saves a draft. Publishing runs the schema check and a target check against a staging build. The editor never ships to normal users; it's a separate lazy chunk behind a role check.
Check your understanding · Exercise 3
A user is on step 3 of tour version 4. The author publishes version 5, which removes step 2. What should happen when the user reloads? Select all that apply.
Decision 3: anchoring a step to an element that may not be there yet
The naive engine does document.querySelector(step.target) when a step starts and positions the bubble next to the result. On a fast laptop in the demo it works, but in production the route changes, the board's data takes 1.8 seconds to load, and the query returns null. The tooltip either throws, appears at the top-left corner of the screen, or the tour silently dies. Data-driven apps render most things late, so waiting for the target is the normal case, not the edge case.
Decision How should the engine wait for a target to appear?
What we pick: Ask the registry first; fall back to a MutationObserver throttled to one query per animation frame, with a slow poll (every 500 ms) as a safety net for visibility changes, all under one timeout per step (default 8 s). A found element only counts once it's actually visible: connected, non-zero size, and checkVisibility() true with its visibilityProperty and opacityProperty options on, so a hidden placeholder doesn't win.
function waitForTarget(find: () => Element | null, ms: number, signal: AbortSignal) {
return new Promise<Element>((resolve, reject) => {
let frame = 0;
const check = () => {
frame = 0;
const el = find();
if (el?.isConnected && el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) && el.getBoundingClientRect().width > 0) done(() => resolve(el));
};
const schedule = () => { if (!frame) frame = requestAnimationFrame(check); };
const observer = new MutationObserver(schedule);
const poll = setInterval(schedule, 500);
const timer = setTimeout(() => done(() => reject(new Error("target-timeout"))), ms);
const onAbort = () => done(() => reject(signal.reason));
function done(settle: () => void) {
observer.disconnect();
clearInterval(poll);
clearTimeout(timer);
cancelAnimationFrame(frame);
signal.removeEventListener("abort", onAbort);
settle();
}
signal.addEventListener("abort", onAbort);
observer.observe(document.body, { childList: true, subtree: true });
check();
});
}
step_target_missing reaches a dashboard, so a broken anchor is found in hours.Scrolling it into view, and following it
A found target may be below the fold or inside a scrolling side panel. element.scrollIntoView({ block: "center" }) scrolls every scrollable ancestor, not just the window, so one call handles nested panels. Use behavior: "smooth" only when the user hasn't asked for reduced motion.
Then position the step with the same library the tooltip guide uses (Floating UI's computePosition with flip and shift), and keep it attached with its autoUpdate, which repositions on ancestor scroll, window resize, and size changes of either element. The engine also keeps a ResizeObserver on the target to move the spotlight cut-out. An IntersectionObserver notices if the user scrolls the target fully out of view; then the step hides its arrow or docks to the edge rather than pointing at nothing.
Targets the DOM can't hand you
- Virtualized lists: row 500 isn't in the DOM until you scroll to it. The list registers a reveal function, like
reveal("task-row", { id }) => list.scrollToIndex(i), the engine calls it, then waits for the row as usual. - Same-origin iframes: query
iframe.contentDocument, then add the iframe's own rectangle to the target's rectangle to get page coordinates. Observe mutations inside the frame's document too. - Cross-origin iframes (an embedded payment form, a third-party editor): the parent can't read their DOM at all. Either the frame loads a small agent that reports target rectangles over
postMessage, or the step anchors to the iframe itself. - Targets behind a closed menu or tab: a step can declare a
beforeaction that the app exposes, like opening the sidebar, rather than asking the user to find it. - Targets that disappear mid-step: the user deletes the card the step points at. The engine sees the element disconnect, hides the step, and waits again with a short timeout before applying the missing-target rule.
When the target never appears
Each step declares what to do on timeout: skip (go to the next step; good for optional pointers), end (stop the tour and record it as broken; good when later steps depend on this one), or float (show the step centered without an arrow; good for content that still makes sense alone).
Whatever happens, log a step_target_missing event with the tour id, version, step id, route and app build. A dashboard that alerts when more than a few percent of step starts hit a missing target catches broken anchors within hours of a release, instead of weeks later when someone notices activation dipped.
Step 1 is showing: its target, the board columns, was already on the page. Press Next.
Check your understanding · Exercise 4
Step 4 points at a task row in a virtualized list of 2,000 tasks. The row is number 1,400. What does the engine need? Select all that apply.
Decision 4: the spotlight overlay
A spotlight dims the whole page except the target, so the eye goes straight to one button. The naive version is a full-screen div with a dark translucent background and z-index: 9999, plus position: relative; z-index: 10000 written onto the target to lift it above the overlay. Some libraries work this way: Intro.js, for example, gives the highlighted element a very large z-index.
It fails in two ways. If any ancestor of the target creates a stacking context (a transform, filter, opacity below 1, or its own z-index), the target's z-index only counts inside that ancestor, so it stays under the overlay. And writing styles onto the app's elements can change their layout and is easy to forget to undo. The spotlight must be drawn around the target, never by changing it.
So the overlay is a separate layer with a hole cut where the target is. There are three common ways to cut the hole, and they differ most in one thing people forget: which clicks get through.
Decision How do we draw the dimmed page with a hole around the target?
What we pick: Use the even-odd SVG path for spotlight steps: it's the only option here where the dimmed area blocks stray clicks and the hole lets the required click through in every engine, which is what a step like "Click Add card to continue" needs. Use the box-shadow version only for decorative, non-blocking highlights. Either way the overlay is aria-hidden="true": it's paint, not content.
function spotlightPath(vw: number, vh: number, r: DOMRect, pad = 8, rad = 8) {
const x = r.left - pad, y = r.top - pad;
const w = r.width + pad * 2, h = r.height + pad * 2;
const hole =
`M${x + rad},${y} h${w - 2 * rad} a${rad},${rad} 0 0 1 ${rad},${rad}` +
` v${h - 2 * rad} a${rad},${rad} 0 0 1 -${rad},${rad}` +
` h-${w - 2 * rad} a${rad},${rad} 0 0 1 -${rad},-${rad}` +
` v-${h - 2 * rad} a${rad},${rad} 0 0 1 ${rad},-${rad} Z`;
return `M0,0 H${vw} V${vh} H0 Z ${hole}`;
}
// svg: pointer-events none; path: pointer-events auto; fill-rule evenodd
// path.setAttribute("d", spotlightPath(innerWidth, innerHeight, target.getBoundingClientRect()));
Staying on top: z-index and the top layer
A tour root at the end of body with a high z-index beats sticky headers and dropdowns, but not the top layer: the layer where modal dialogs, popovers and fullscreen elements live, above every z-index. So, as the tooltip guide does for its bubble, show the tour root with the Popover API (popover="manual" and showPopover()), which puts the overlay and step in the top layer above everything opened before them.
Modal dialogs need one more step. An open modal makes everything outside itself inert, so a tour root elsewhere in the DOM would paint above the dialog but ignore every click. Mount the tour root inside the target's closest open dialog element when there is one, so it stays clickable. The modal-dialog guide explains the top layer and inertness; here we only apply them.
Clicks and keys are separate questions. The overlay blocks mouse clicks outside the hole so a user doesn't navigate away by accident on a required step, and clicking the dimmed area does nothing, or at most asks "Exit the tour?". It must not trap the keyboard: Tab still moves through the page, Escape ends the tour, and the step keeps focus where the user expects. A tour is guidance, not a lock.
Click the Share button in the hole, or the Delete button in the dimmed area.
Screen reader on step 2, focus on the Share button (the target): “Share, button. Click Share to continue. Step 2 of 3.”
Check your understanding · Exercise 5
A spotlight step says "Click Share to continue". Users report they can't click Share. Which causes fit? Select all that apply.
Decision 5: progress, resume, and tours that cross pages
A user finishes step 2 of 4, then their laptop reloads to install an update. The next morning they open the app on a desktop at work. What should they see? Not step 1 again, and not the welcome modal again.
If the tour's progress lives in React state, the reload wipes it. If it lives in localStorage, the reload is fine but the desktop knows nothing, and a shared or cleaned browser loses it too. The checklist is worse: it spans days, so it must be the same everywhere the user signs in.
Decision Where does tour and checklist progress live?
What we pick: Server as truth with a local mirror and queued writes. The merge rule is simple because progress only moves forward: completed step ids are a set union, a later step wins over an earlier one, and dismissed or completed beats in progress. That makes writes safe to retry and to arrive out of order, and it means two tabs can't drag a user backwards.
The lifecycle of one tour
Write the tour's life down as a state machine, because the bugs live in the transitions: a target that disappears while a step is showing, a reload in the middle of waiting, a dismiss during navigation. Each user and tour pair moves through eligible, running (with a current step that is either waiting for its target or showing), and ends in completed, dismissed, or broken (a required target never appeared). snoozed is a pause with a wake-up time: "remind me tomorrow". Only the end states stop the tour from being offered again.
snoozed is a pause, and a reload in waiting simply waits again.Tours across routes in a single-page app
A step can name a route. When the next step's route differs from the current one, the engine has two choices. If the step says navigate: true, it calls the app's router through an adapter (router.push("/settings/billing")), never location.href, which would reload the page and the engine. Otherwise it shows the step's "Go to Billing" button, or waits for the user to get there, with a longer timeout.
Progress is saved before any navigation, so even a full page load resumes at the right step. The engine subscribes to route changes and pauses a step when its route is left. The user who wanders off to their inbox mid-tour sees nothing until they come back, and the checklist offers "Resume tour".
One user, two tabs: both could start the same tour. A BroadcastChannel("tours") message announces "tab A is running first-board"; tab B stays quiet, and picks up the updated progress when it becomes visible. The cross-tab-sync lesson covers the pattern.
Checklists that complete when the real thing happens
The naive checklist ticks "Invite a teammate" when the user clicks the Invite button. But clicking isn't inviting: they may close the dialog, or the invite may fail. And they may invite someone from the mobile app or via the API, which the web tour never sees.
So checklist items complete on facts, not clicks. The app already emits domain events (member.invited, project.created) to its backend; the checklist worker on the server listens to those events and marks items done. The web client hears about it through the next state fetch or a push message, and ticks the item with a short confirmation. A small client-side hint ("we just saw an invite succeed") can tick the box optimistically, but the server's fact is the truth.
Check your understanding · Exercise 6
Tab A reports step 3 completed; a delayed retry from tab B arrives later saying step 2 is current. What should the server store? Select all that apply.
Decision 6: who sees which tour, and when to stay quiet
Picture a Monday morning. The growth team's onboarding tour, the editor team's "New: Automations" hotspot, and the research team's survey modal each check their own condition on page load, and all three are true. The user sees a modal on top of a spotlight next to a pulsing beacon, while they were trying to reply to a comment.
Every team's tour was reasonable alone; together they make the product feel hostile. Tours need one shared decision about what to show, not one per team.
Decision Where is the decision "which tour, if any, right now" made?
What we pick: Server shortlists, client arbitrates. The server applies everything it knows: segment and plan through the feature-flag-sdk's targeting rules, "already completed or dismissed", and frequency caps. The client applies what only it knows: the trigger, whether the target exists, and whether the user is busy. One guidance slot means one interruption at a time.
| Rule | Where | Example value |
|---|---|---|
| Audience | Server | Role is editor, plan is Team, signed up 14 days ago or less |
| Already done | Server | Skip if the user completed the action the tour teaches, like already created a board |
| Show once | Server | Never re-offer after completed or dismissed, unless the author publishes a new major version and opts in |
| Frequency cap | Server | At most 1 tour per session and 3 per week across all tours |
| Global opt-out | Server | "Don't show tips" in settings turns off all push tours; the checklist stays available |
| Priority | Both | Onboarding 100, feature announcement 50, survey 10; highest eligible wins the slot |
| Trigger | Client | First visit to the board route, or the card.created event |
| Not busy | Client | No focused text field, no drag in progress, no open dialog, 2 s since the last keystroke |
The "already done" rule is the cheapest win and the most often missed. An experienced user who joins a new workspace gets a fresh account, but they already know how to make a board. If the server sees they created boards in another workspace, or imports a project in their first minute, the beginner tour is wasted on them. Rules based on what the user has actually done beat rules based on account age.
Dismissal must be clear and respected: every step has a visible close button, Escape works, and the first close asks nothing and records dismissed for that tour. A separate, explicit "Don't show tips like this" option in the step and in settings sets the global opt-out. Never re-show a dismissed tour because the author edited a typo. The notifications-center guide makes the same argument about interruption budgets; tours and notifications should share one.
Check your understanding · Exercise 7
Which checks belong in the client rather than the server? Select all that apply.
Architecture, data model and API
With the decisions made, the parts fall out of them. The key split is between what every session loads and what only eligible sessions load. Every page carries a tour loader of about 2 KB; everything else, the engine, positioning library and step UI, is a separate chunk loaded only when the server says there's something to show.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Tour loader (main bundle) | After the app is interactive, ask the eligibility API; if the list is empty, stop; else import the engine | Keeps the cost near zero for the 90 percent or more of sessions with no tour |
| Tour runner | The state machine per tour: current step, waiting, showing, ending; applies the missing-target rule | Pure logic, framework-free, so it's unit-testable without a DOM |
| Anchor resolver | Registry lookups, MutationObserver wait, reveal functions, visibility check, iframe offsets | All DOM fragility in one place, with one timeout policy |
| Positioner and overlay | Floating UI placement plus the even-odd spotlight path; follows scroll and resize | Reuses the tooltip guide's positioning; nothing tour-specific |
| Step view | The step dialog: heading, body, progress, Back, Next, Close; focus and announcements | The only React part; swappable for a different design system |
| Progress store | Local mirror plus a retrying write queue with a forward-only merge | Steps never wait on the network |
| Guidance slot | One interruption at a time, by priority; shared with announcements and surveys | Stops teams' tours from stacking |
| Tour events | Emits tour_step_shown and friends to the analytics SDK | Analytics stays the analytics SDK's job |
On the server side there are four small pieces. A definitions service stores versioned tour JSON; published versions are immutable, so they're served from the CDN with a long cache lifetime under a versioned URL. An eligibility API returns, per user, the short ranked list with the user's progress for each; it's personal, so it's Cache-Control: no-store.
A progress API accepts idempotent step updates. A checklist worker listens to domain events like project.created and marks checklist items done. Experiments come from the existing A/B system, and audience rules from the flag service.
no-store APIs; writes and events flow back on their own paths. The checklist is fed by server-side facts, not by clicks in the page.Think first
Why is the runner framework-free when the app is written in React?
The runner is the part with the subtle logic: waiting, timeouts, the missing-target rules, the merge. Keeping it free of React makes it testable with plain unit tests and fake timers, and lets a second app, like an admin console in another framework, reuse it with its own step view.
React enters only at the edges: a useTourAnchor hook to register targets, and the step view component. That's the same split a good analytics or flag SDK uses.
Data model: three kinds of data, three homes
Three kinds of data, with three very different lifetimes and homes. Definitions are shared by everyone, change when authors publish, and are cached for a long time. Progress is per user and changes every few seconds during a tour. Live step state (the resolved element, its rectangle, whether we're waiting) exists only in memory for the current page and is never saved.
type Target = { tour: string; nth?: number } | { selector: string };
type Step = {
id: string; // stable across versions
kind: "modal" | "tooltip" | "spotlight" | "hotspot";
target?: Target; // none for a centered modal
route?: string;
title: string;
body: string; // plain text or tiny Markdown, never HTML
placement?: "top" | "bottom" | "left" | "right";
advanceOn?: { click: "target" } | { event: string };
onMissingTarget?: "skip" | "end" | "float";
waitMs?: number; // default 8000
};
type TourDefinition = { id: string; version: number; priority: number; steps: Step[] };
type TourProgress = {
tourId: string;
version: number; // the version this user started
status: "eligible" | "running" | "snoozed" | "completed" | "dismissed" | "broken";
currentStepId?: string;
completedStepIds: string[]; // set union on merge
snoozedUntil?: string; // ISO time
updatedAt: string;
};
type LiveStep = { stepId: string; phase: "waiting" | "showing"; el?: Element; rect?: DOMRect };
| Data | Lives in | Cached how |
|---|---|---|
| Tour definition (versioned JSON) | CDN, then memory | Immutable URL per version, max-age of a year |
| Eligible list and progress | Server; mirrored in memory and localStorage | no-store; mirror used only to resume instantly |
| Checklist items and done state | Server; derived from domain events | Fetched with eligibility, updated by push or refetch |
| Live step (element, rect, phase) | Runner memory only | Never saved; recomputed after reload |
| Global opt-out | Server user settings | Read with eligibility |
The HTTP API
GET /api/tours/eligible?route=/projects/42/board
200 OK Cache-Control: no-store
{
"tours": [
{ "id": "first-board", "version": 4, "priority": 100,
"definitionUrl": "https://cdn.example.com/tours/first-board/v4.json",
"trigger": { "route": "/projects/:id/board", "firstVisit": true },
"progress": { "status": "running", "currentStepId": "add-card",
"completedStepIds": ["columns"] },
"experiment": { "key": "tour-first-board", "arm": "tour" } }
],
"checklist": [ { "id": "create-project", "done": true },
{ "id": "invite-member", "done": false } ],
"caps": { "sessionRemaining": 1 }
}
PUT /api/tours/first-board/progress
Idempotency-Key: 7c1e...
{ "version": 4, "status": "running", "currentStepId": "drag",
"completedStepIds": ["columns", "add-card"] }
200 OK { "status": "running", "currentStepId": "drag", ... }
The server returns the merged state on every write, so a tab that sent a stale update learns the truth in the reply. Errors follow the app's usual shape; a 409 never happens, because the forward-only merge has no conflicts to report. If the eligibility call fails or takes longer than about 3 seconds, the loader simply shows nothing: a missing tour is always better than a slow app.
The client API for app developers
interface Tours {
start(tourId: string, opts?: { stepId?: string; force?: boolean }): Promise<void>;
stop(reason?: "dismissed" | "completed"): void;
snooze(tourId: string, until: Date): void;
track(event: string, data?: Record<string, unknown>): void; // e.g. "card.moved"
registerReveal(anchor: string, reveal: (nth?: number) => Promise<void>): () => void;
on(event: TourEvent, cb: (e: TourEventData) => void): () => void;
hooks: {
beforeStep?: (step: Step) => Promise<boolean | void>; // return false to skip
afterStep?: (step: Step) => void;
};
}
type TourEvent = "tour_started" | "step_shown" | "step_next" | "step_back"
| "step_target_missing" | "tour_dismissed" | "tour_completed";
// React: mark an anchor without touching the DOM by hand
function AddCardButton() {
const ref = useTourAnchor<HTMLButtonElement>("add-card");
return <button ref={ref} data-tour="add-card">Add card</button>;
}
start(id) is how a checklist item or a Help menu "Show me around" button launches a tour on demand. A start the user asked for skips the frequency cap and the global opt-out, since both exist to limit tours nobody asked for; automatic starts respect both. beforeStep lets app code prepare the UI, like opening a collapsed panel, and return false to skip a step that doesn't apply. Events go through the analytics SDK, so they share batching, consent and sampling with everything else; the analytics-tracking lesson covers event naming and schemas.
Check your understanding · Exercise 8
Which choices in this data model prevent real bugs? Select all that apply.
Measuring and performance: does it help, and what does it cost?
The dashboard says the board tour has a 62 percent completion rate. Is that good? It can't tell you, because people who finish a tour are often the ones who were going to succeed anyway, and a tour can be finished by clicking Next four times without reading anything. Completion measures the tour; the business wants to know about the user: did more people reach activation, like "created a board and moved a card within 7 days"?
Decision Which measure decides whether a tour stays?
What we pick: Use the activation lift against a holdout to decide whether a tour stays, and the per-step funnel to fix it. Completion rate is a health signal at most. The experiment machinery, assignment and statistics, comes from the ab-testing-system guide; the tour system only has to log exposure correctly.
step_target_missing, an engineering bug, not a content problem.The subtle part is exposure: the moment a user counts as being in the test. If the tour arm counts only users who saw the tour, and the control arm counts every eligible user, the comparison is broken: the tour arm is filtered to people who reached the board route, who are already more engaged.
The fix is to log tour_exposure in both arms at the same moment: when the trigger fires and the client would start the tour. The control arm logs it and then shows nothing. Now both groups are "users who reached the moment", and the only difference is the tour.
Beyond single tests, keep a small global holdout: say 5 percent of new sign-ups who get no push tours at all for their first 30 days (the checklist still shows, since it is the accessible baseline). Each tour may win its own test, while ten tours together still make onboarding noisier. The holdout measures the whole program. Also watch the cost side: support tickets mentioning "popup", the global opt-out rate, and how many users dismiss on step one.
Check your understanding · Exercise 9
The tour arm shows 18 percent higher activation than the control arm. Which flaws would make that number untrustworthy? Select all that apply.
Performance: pay only when there is a tour
Third-party tour tools are often added as a script tag in the page head, and every visitor downloads and runs them on every page, whether a tour shows or not. For an app watching its Core Web Vitals (LCP at most 2.5 s, INP at most 200 ms, CLS at most 0.1, at the 75th percentile), that's the wrong default. Our budget from the requirements: zero engine bytes for ineligible sessions, and no measurable change to LCP or INP for anyone.
- Ask late, ask cheaply. The loader waits until after the app's first render and its main data, then asks for eligibility. Better still, the session bootstrap response the app already fetches can include the tour shortlist, saving a round trip.
- Import only when eligible. If the list is empty, nothing else happens. If not,
import("./tour-engine")fetches the engine chunk (our target is under 25 KB gzipped, including the positioning library) and the needed definition JSON in parallel. The code-splitting lesson covers chunk boundaries; the tour engine is a textbook split point. - Don't block input. Start the engine in an idle moment:
requestIdleCallbackwhere it exists, a shortsetTimeoutwhere it isn't available. The first step appears only after its trigger, so a delay of a few hundred ms is invisible. - Keep the observers cheap. The MutationObserver callback only schedules one query per animation frame, and stops as soon as the target is found. Scroll and resize handling reads rectangles first and writes positions after, in one frame, to avoid layout thrashing.
- Never shift the layout. Steps, overlays and beacons are
position: fixedor absolutely positioned in their own layer, so they don't move page content and add nothing to CLS. A tour banner inserted at the top of the page in normal flow would shift everything below it. - Tear down fully. On end, disconnect observers, remove listeners, and drop references to DOM elements, so a long session doesn't keep detached nodes alive.
Put numbers on it in the interview. A loader of about 2 KB plus one eligibility request, often folded into a request the app already makes, is the whole cost for most sessions. An eligible session adds one chunk of about 25 KB gzipped and one small JSON file, fetched after the page is already usable. That is less than a single product image, and it arrives when nobody is waiting for it.
Check your understanding · Exercise 10
Which changes keep the tour system from hurting the app's Core Web Vitals? Select all that apply.
Accessibility: a tour everyone can follow
Tours are often among the least accessible parts of an app. A typical one is a floating div appended to the end of the body: a screen reader never hears it appear, keyboard focus stays behind on the page, the Next button is unreachable without a long Tab journey, and the pulsing beacon is a div with a click handler. The fix isn't exotic. It's the patterns from the aria-patterns, focus-management and keyboard-navigation lessons applied with care.
- Each step is a non-modal dialog:
role="dialog"witharia-labelledbypointing at its heading andaria-describedbyat its body. Noaria-modal, because the page stays usable behind it. - Say where you are: the heading or a line under it reads "Step 2 of 4", so the count is announced with the step, not left to a visual progress bar.
- Focus moves to the step, and comes back: when a step opens, focus goes to the step container (with
tabindex="-1") so its name and description are read. When the tour ends, focus returns to the element that had it before the tour started, or to the target the user just used. - Required-action steps focus the target: for "Click Add card to continue", put focus on the Add card button and temporarily point its
aria-describedbyat the step body. The user hears "Add card, button, Click Add card to continue, step 2 of 4" and can press Enter at once. Restore the original attribute afterwards. - Keyboard controls: Next and Back are real buttons; Escape dismisses the tour when focus is in the step or on its target (elsewhere, the app's own Escape behavior, like closing a menu, wins). No focus trap: Tab can leave the step, and a "Return to tour" item in the checklist or Help menu brings it back.
- Hotspots are buttons: a
buttonwith an accessible name like "Tip: Automations" andaria-expanded, not a decorative dot. - Reduced motion: under
prefers-reduced-motion: reduce, no pulsing beacons, no smooth scrolling, and the spotlight jumps between targets instead of gliding. - No timers the user can't control: steps never advance on a clock, since WCAG 2.2.1 (Timing Adjustable) would then require a way to turn off, adjust or extend the timer. A beacon that pulses on its own for more than 5 seconds next to other content falls under 2.2.2 (Pause, Stop, Hide), so stop the pulse after a few cycles or give it a way to stop.
- Small screens and zoom: at 320 CSS px wide, or at 400 percent zoom, there's no room beside the target, so the step becomes a sheet docked to the bottom of the viewport.
The overlay itself is aria-hidden="true": dimming is visual emphasis, with nothing to read. The checklist is the text alternative for the whole program: a plain list with a heading, each item a link or button that explains the task in words and can start its tour. A user who dismisses every tour, or whose assistive technology struggles with floating UI, can still learn everything from it. The spotlight lab above shows what a screen reader announces at each step, with the overlay hidden from assistive technology and without.
Check your understanding · Exercise 11
A keyboard user is on a step that says "Click Share to continue". What should be true? Select all that apply.
Running it in the interview
Forty-five minutes go fast. A plan that works: 5 minutes on requirements (who authors tours, which types, devices, how success is measured), 3 minutes naming what makes it hard, then draw the architecture with the loader and lazy engine split. Spend the middle 20 minutes on two or three deep dives the interviewer cares about: usually anchoring to late targets, progress and resume, and targeting rules. Close with measurement, performance and accessibility in 5 minutes, and keep the rest for follow-ups.
Draw first: the host app with a few data-tour anchors, the 2 KB loader, the eligibility API, and the lazy engine with its runner, anchor resolver, overlay and progress store. Say out loud the two sentences that frame everything: "Tours are data, published by non-engineers, so the engine must survive targets that are late, moved or missing," and "Most sessions should pay nothing."
Follow-up questions to expect
Think first
The design team ships a redesign next week. How do you keep 50 live tours from breaking?
Targets are data-tour ids, so a restyle doesn't touch them. A CI check lists every id used by published tours and fails the build if one disappears. Staging runs each tour headlessly against the new build and reports steps whose target times out.
In production, the step_target_missing rate per step is alerted on, so anything that slips through is caught within hours, and steps fall back to skip or float instead of breaking.
Think first
How does this work on a phone, where there's no room for a bubble next to a button?
The step view switches to a bottom sheet below a breakpoint, about 600 px wide, keeping the spotlight on the target above it. Hotspots get tap targets of at least 24 by 24 CSS px (the WCAG 2.2 AA minimum), and 44 by 44 is more comfortable. Required-action steps still work, since the hole lets the tap through.
Native mobile apps would reuse the same definitions and progress API, with native step views; that's why definitions are data and the runner is framework-free.
Think first
Why not just buy Appcues or Pendo?
Often you should: they bring the editor, targeting and analytics on day one. The trade-offs are a third-party script on every page (performance and security review, CSP entries), selectors recorded by their editor unless you add stable hooks, and less control over accessibility and design-system consistency.
A strong answer names the criteria: number of tours, need for in-app actions and app events, accessibility requirements, and whether the data may leave your infrastructure.
Summary
- A product tour is a guest on a moving host: design for targets that are late, moved, hidden in scroll containers, virtualized, in iframes, or missing.
- Prefer an always-available checklist that completes on real server-side facts, plus short in-context micro-tours, over a long tour on first login that users skip and forget.
- Tours are versioned data published by non-engineers; steps point at stable
data-tourids, and progress stores the version and step id, never an index. - Wait for targets with a registry first, then a frame-throttled MutationObserver with a timeout, and apply a per-step skip, end or float rule while logging the miss.
- Draw the spotlight with an even-odd SVG path so the dim area blocks stray clicks and the hole passes the required one; show the tour in the top layer as a manual popover, mounted inside an open modal dialog when the target is in one.
- Progress lives on the server with a local mirror and a forward-only merge, so it resumes across reloads, tabs and devices.
- The server shortlists by audience, history and caps; the client arbitrates one guidance slot by trigger, target presence and whether the user is busy.
- Judge tours by activation lift against a holdout, with exposure logged in both arms; use per-step funnels to fix them.
- Load the engine lazily only for eligible sessions, and build each step as a non-modal dialog with focus that moves in and back, Escape, step counts, and reduced motion.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Onboarding Tutorials vs. Contextual Help (Nielsen Norman Group) Why push tutorials are skipped and forgotten, and the case for pull revelations.
- MutationObserver (MDN) The API for waiting on late targets.
- Element.scrollIntoView() (MDN) Scrolling a target into view, including nested scroll containers.
- fill-rule (MDN) How evenodd turns an inner shape into a hole.
- CSS Masking Module Level 1 (W3C) Clipping versus masking: clip-path removes the clipped area from hit-testing, and the CSS Working Group resolved in 2025 that masks don't change it.
- Floating UI: autoUpdate Keeping a floating element attached through scroll, resize and layout changes.
- Dialog (Modal) pattern, WAI-ARIA Authoring Practices Guide Dialog roles, labels and focus rules; tour steps drop
aria-modaland the focus trap because the page stays usable. - Popover API (MDN) Putting the tour root in the top layer with
popover="manual"andshowPopover(). - Understanding SC 2.2.2: Pause, Stop, Hide (W3C) Why a beacon that pulses for more than 5 seconds needs a way to stop.
- Mobile Tutorials: Wasted Effort or Efficiency Boost? (Nielsen Norman Group) 70 users, four apps: people who read tutorials did no better than people who skipped them.
- Chameleon benchmark report Vendor data on tour completion by length and trigger; directional, from one platform's customers.
- Shepherd.js documentation An open-source tour library with steps, attachTo targets and a modal overlay.
- Driver.js documentation A small open-source tour library whose overlay is one SVG path with an even-odd hole.
- Web Vitals (web.dev) LCP, INP and CLS thresholds the tour must not move.
Concepts behind it
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Feature flagsSwitches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.