cf.completefrontend
My progress
SYSTEM DESIGN · #59

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

RequirementTargetWhy it matters
Cost to users with no tour0 KB of tour engine downloaded; one small eligibility checkOver 90 percent of sessions are not eligible for any tour. They must pay nothing.
Engine size when neededUnder 25 KB gzipped, loaded lazilyIt loads after the app is interactive, so it must not delay LCP or block input.
Step appears after target is readyUnder 100 msA tip that lags behind the UI it describes feels broken.
Wait for a late targetUp to 5 to 10 s, then skip or endData-driven UI can render seconds after navigation; never wait forever.
Progress savedWithin 1 s of each step change, survives reload and device switchPeople sign up on a laptop and continue on another one the next day.
AccessibilityWCAG 2.2 AA: keyboard, screen reader, reduced motionTours are often the least accessible part of an app.
Scale2 million monthly users, about 50 active tours, 5 to 15 steps eachTour 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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.

Wireframe of a project board app with the kinds of guidance on it. On the left, a sidebar lists Projects and Automations, with a small hotspot dot next to Automations. The board has three columns: To do, Doing and Done. In To do, a card called Task A and an Add card button, which is the spotlight target, outlined by a dashed hole. A step bubble to its right reads Add a task, step 2 of 3, with Back, Next and Close buttons, and points at the button. Below, a checklist note reads Get started, 2 of 5, done: create project, next: invite a teammate. A second note says the welcome modal shows alone, once.Project boardProjectsAutomationshotspotTo doDoingDoneTask AAdd cardspotlight targetAdd a taskstep 2 of 3Back Next CloseGet started: 2 of 5done: create projectnext: invitewelcome modal:alone, first visit
One screen, four kinds of guidance. Only one of them interrupts at a time: here the step bubble, while the hotspot waits quietly and the checklist stays available.

Decision Which form of guidance should carry new-user onboarding?

Linear tour on first login (welcome modal plus a chain of step tooltips)

Right after signup, a modal says hello, then 5 to 10 tooltips point at areas of the UI one by one, with Next and Back.

Optionally each step dims the page with a spotlight.

Pros

  • Simple to build and to reason about: one sequence, one entry point.
  • Good for one novel concept the user can't discover alone, like a canvas gesture.

Cons

  • Push revelation: it interrupts the first thing the user wanted to do.
  • Long tours lose most people before the end; the content is forgotten by the time it's needed.
  • Points at empty UI: a brand-new account has no projects to point at.

Contextual micro-tours (2 to 4 steps, triggered by what the user is doing)

A short tour starts when the user first reaches a screen or first opens a feature, like the first visit to the board view.

Steps can require the real action: "Drag this card to Done" waits until the card moves.

Pros

  • Help arrives when it's relevant, so it's remembered.
  • Short enough to finish; required actions teach by doing.

Cons

  • Needs a trigger system (route, event, element visible) and rules so micro-tours don't pile up.
  • More tours to author and test.

Hotspots and beacons (pull)

A small pulsing dot sits next to a feature. Nothing opens until the user clicks or focuses it; then one tip appears.

Pros

  • Never blocks work: the user decides.
  • Great for announcing a new feature to existing users.

Cons

  • Easy to ignore, so poor for must-know steps.
  • Pulsing animation must respect reduced-motion settings, and the dot must be a real button.

Checklist driven by real actions

A panel lists 4 to 7 tasks: create a project, invite a teammate, connect Slack. Each ticks when the app reports the real event.

Each item can launch a micro-tour for that task.

Pros

  • The user chooses the order and the time; progress is visible and motivating.
  • Measures real activation, not clicks on Next.
  • Doubles as the accessible, text-only version of the whole onboarding.

Cons

  • Needs server-side progress and app events wired to items.
  • A checklist alone doesn't show where a button is.

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.

GuidanceBest forTypical lengthInterrupts?
Welcome modalGreeting, one choice: start setup or later1 screenYes, once
Step tooltipPointing at where something lives2 to 4 stepsPartly: page stays usable
Spotlight stepOne required click the user must not miss1 step inside a tourYes: page dimmed
HotspotNew or optional features1 tipNo
ChecklistActivation tasks over days4 to 7 itemsNo

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.

json
{
  "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 }
  ]
}
A published tour definition. Steps name targets by stable data-tour ids, not CSS paths.

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?

Recorded CSS selector or XPath

The visual editor records whatever selector the clicked element happens to have, like main > div:nth-child(2) button.css-1x9k.

Pros

  • Works on any app with zero engineering help; this is how many no-code tools start.

Cons

  • Breaks on any markup change, class hashing, or reordering. Failures are silent.
  • May match the wrong element after a change, which is worse than matching nothing.

Stable data-tour attributes

Engineers add data-tour="add-card" to elements that tours may point at. The engine queries [data-tour="add-card"].

The editor shows only elements that carry a hook, and asks for a new one otherwise.

Pros

  • A visible contract in the code: a reviewer sees that renaming it breaks a tour.
  • Survives restyling and restructuring; easy to lint and to test.

Cons

  • Needs a small engineering step for each new anchor.
  • Ids must be unique per screen or carry an index (nth).

Anchors registered from components

Components call a hook, like useTourAnchor("add-card", ref), that registers the element in a registry when it mounts and removes it when it unmounts.

Pros

  • The engine is told the moment a target mounts: no DOM polling at all.
  • Works for elements a selector can't reach easily, like canvas regions or rows in a virtualized list, via a custom reveal function.

Cons

  • Ties tours to one framework.
  • Only works in code you own; a third-party widget can't register itself.

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.

A timeline of tour versions. Version 4 is published first. User A starts it and completes steps 1 and 2, and is on step 3 when version 5 is published, which removes step 2. After that, user A keeps version 4 and continues at step 3. User B starts after the publish and gets version 5.versionsuser Auser Bv4 publishedv5: step 2 removed123A keeps v4, step 3B starts v5publish v5
Published versions never change. Progress stores 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?

Polling

Every 100 to 250 ms, query the selector; give up after a timeout.

Pros

  • Tiny and obvious. Also catches changes the observer isn't watching, like an element becoming visible through a class change on an ancestor.

Cons

  • Up to one interval of delay (a visible lag at 250 ms).
  • Burns a little CPU on every tick, for every waiting step.

MutationObserver

Observe document.body with childList and subtree; on each batch of mutations, re-run the query, at most once per animation frame.

Disconnect when found or when the timeout fires.

Pros

  • Reacts within a frame of the element being inserted.
  • No work while the DOM is quiet.

Cons

  • A busy page can fire thousands of mutations a second, so the check must be cheap and throttled.
  • Misses pure style changes unless you also watch attributes, which gets expensive.

Anchor registry

Components register their anchors on mount (useTourAnchor). The engine subscribes to the id and is called back the moment it mounts.

Pros

  • Instant and free: no DOM scanning.
  • Can also expose a reveal function for elements that aren't in the DOM yet.

Cons

  • Only for components that opted in.

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.

ts
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();
  });
}
Wait for a visible target, cheaply, with a timeout and cancellation.
Sequence diagram of a step waiting for its target, between the Runner, the Anchor resolver, the App DOM and Analytics. The Runner asks the resolver to wait for add-card for up to 8 seconds. The resolver queries the DOM and gets null, so it starts observing mutations. After 1.8 seconds the button is inserted and the resolver returns the visible element. The Runner scrolls it into view and shows the step. Below a dashed line, the failure path: if it never appears, the resolver reports a timeout after 8 seconds, and the Runner applies skip, end or float and sends step_target_missing to Analytics.RunnerAnchor resolverApp DOMAnalyticswait add-card, 8 squery: nullobserve mutationsinserted at 1.8 svisible elementscroll, show steptimeout at 8 sskip, end or floatstep_target_missing
The normal case is a target that arrives late, not one that is already there. On timeout the step degrades by its own rule, and 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 before action 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.

If missingProgress in

Step 1 is showing: its target, the board columns, was already on the page. Press Next.

A mock project board running a 3-step tour. Status: step 1 of 3: showing. Waited 0 seconds. The Add card button is on the page, below the fold. The Invite button is on the page. Saved on the server: columns, running, completed none.Project boardProjectsTo doDoingDonebelow foldAdd cardInviteYour workflowstep 1 of 3step 1 of 3: showingwaited: 0 s of 8server: columns, runningdone: none
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.

Three spotlight techniques side by side, each a window with a dimmed page and a clear hole around a Share button. Box-shadow: clicks outside pass through to the page, and clicks in the hole reach Share. SVG mask: clicks outside are blocked, and by the spec clicks in the hole are blocked too, though browsers differ. Even-odd path: clicks outside are blocked, and clicks in the hole reach Share.box-shadowShareoutside: clicks passhole: clicks passSVG maskShareoutside: blockedhole: blocked by speceven-odd pathShareoutside: blockedhole: clicks pass
All three look identical. Only the even-odd path reliably blocks stray clicks on the dimmed page and lets the required click reach the target, because its hole is empty for hit-testing in every engine. A mask changes paint only, by the spec.

Decision How do we draw the dimmed page with a hole around the target?

Box-shadow cut-out

Place a transparent div with pointer-events: none exactly over the target's rectangle and give it box-shadow: 0 0 0 9999px rgb(0 0 0 / 0.5) and a border-radius. The huge shadow dims everything around it. Intro.js draws its highlight layer with a shadow like this.

Pros

  • One element, one CSS rule; the rounded hole comes free from border-radius.
  • Easy to animate between steps with a transition on position and size.

Cons

  • A box-shadow isn't hit-tested: clicks on the dimmed area go straight through to the page, so it can't block anything.
  • To block clicks you need extra elements anyway.

SVG mask

A full-viewport svg draws a dark rect, with a mask whose white area is the viewport and black area is a rounded rectangle at the target.

Pros

  • Any hole shape: rounded, several holes, soft edges with a blur.

Cons

  • The CSS Masking spec says a mask changes only what's painted, not hit-testing, so the dark rect still receives clicks inside the hole. Turning pointer events off fixes the hole but lets everything through.
  • Browsers have not agreed here: WebKit ignores the mask for hit-testing, while Chrome and Firefox have been reported to consult it. A required click shouldn't depend on the engine.

SVG path with an even-odd hole

A full-viewport svg with one path: an outer rectangle plus an inner rounded rectangle, drawn with fill-rule: evenodd so the inner one becomes a hole.

The svg has pointer-events: none; the path has pointer-events: auto. Driver.js and Shepherd.js draw their overlays as a single SVG path with a hole like this.

A full-viewport div with clip-path: path(evenodd, ...) behaves the same way, because clipping also removes the clipped area from hit-testing.

Pros

  • The painted area blocks clicks; the hole really is empty, so clicks inside it reach the target.
  • One element; the hole moves by changing one d attribute, which is cheap to animate.

Cons

  • Rounded corners need arc commands in the path string; a bit more code than a shadow.
  • Must be recomputed on every scroll and resize, like any overlay.

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.

ts
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()));
The overlay path: a viewport rectangle with a rounded hole. even-odd fill makes the inner shape a hole for both paint and clicks.

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.

Overlay
Step

Click the Share button in the hole, or the Delete button in the dimmed area.

A file app dimmed by a even-odd path overlay with a hole around the Share button; Delete sits in the dimmed area. Click the Share button in the hole, or the Delete button in the dimmed area. On step 2, focus goes to the Share button (the target), and a screen reader hears: Share, button. Click Share to continue. Step 2 of 3.FilesShareDeleteRenameShare a filestep 2 of 3overlay: even-odd pathhole: clicks passoutside: blockedfocus: step 2overlay: silenttarget described by step
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?

Browser storage only

Write { tourId, version, stepId, status } to localStorage on each step change.

Pros

  • Instant reads, no backend work, works offline.

Cons

  • Per browser, not per user: a second device or a cleared profile starts over.
  • Can't be used for targeting on the server or in analytics joins.

Server per user, read on every step

The engine calls GET /tour-state before each step and PUT after each one.

Pros

  • One truth across devices; easy to query and to reset from support tools.

Cons

  • A network round trip before every step adds 100 to 300 ms of lag.
  • A failed write loses progress unless retried.

Server as truth, local mirror, queued writes

Load the user's tour state once with the eligibility check. Keep a copy in memory and in localStorage.

Apply step changes locally first, then send them to the server through a small retrying queue; flush with fetch(..., { keepalive: true }) when the page is hidden.

Pros

  • Steps advance instantly; reloads resume from the local mirror even before the network answers.
  • Other devices get the server's state on their next load.

Cons

  • Two copies can disagree; needs a simple merge rule.

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.

Sequence diagram of saving and resuming progress, between the User, the Engine, the Local mirror and the Server. The user clicks Next on step 2. The engine saves step 3 to the local mirror and shows step 3 at once. Then it sends PUT progress to the server, retried if offline, and the server replies with the merged state. Later the user reloads the page. The engine reads step 3 from the mirror and fetches the eligible list with progress from the server, which confirms step 3.UserEngineLocal mirrorServerNext on step 2save step 3show step 3 at oncePUT, retriedmerged statereload pageread mirror: step 3GET eligibleprogress: step 3
The step never waits for the network: the mirror answers first, and the server is the truth that settles any difference. A forward-only merge makes the retried PUT safe to send twice.

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.

State machine of one tour for one user. Eligible goes to running on start. Inside running, waiting goes to showing when the target is found, and showing goes back to waiting on Next or when the target is lost. Waiting ends in broken on a target timeout. Showing ends in dismissed on Close or Escape, and in completed when the last step is done. Showing can also go to snoozed on later, and snoozed returns to eligible at its wake-up time.eligiblesnoozedrunningwaitingtarget?showingstep visiblebrokentarget timeoutdismissedClose or Escapecompletedlast step donestartfoundNextlaterwake up
Only the three states on the right 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?

Each tour decides in the client

Every tour ships with its own shouldShow() check that runs on page load.

Pros

  • No central service; each team is independent.

Cons

  • Tours stack, because nothing compares them.
  • Every tour definition must ship to every client to be evaluated.

Server picks exactly one

On load, the server evaluates audience, history and caps, and returns the single tour to show.

Pros

  • One place for caps and priority; tours the user can't see never reach the client.

Cons

  • The server can't know live client facts: which route is open, whether the target exists, whether the user is mid-typing.

Server shortlists, client arbitrates

The server returns a short, ranked list of eligible tours (usually 0 to 3) with their triggers.

The client starts at most one, when its trigger fires and the user isn't busy, through a single guidance slot shared by tours, announcements and surveys.

Pros

  • Static rules (segment, history, caps) run where the data lives; live rules run where the UI lives.
  • Ineligible users download nothing but an empty list.

Cons

  • Two places to reason about; the contract between them must be explicit.

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.

The eligibility pipeline in two zones. On the server: 50 published tours, filtered by audience such as role and plan, then by history and caps. A shortlist of 0 to 3 tours crosses the network to the client. In the client: has the trigger fired, is the target ready and the user idle, and finally one guidance slot that shows at most one tour.Server50 published toursaudience: role, planhistory and capsClienttrigger fired?target ready, user idle?guidance slot: 1 toura shortlist of 0 to 3 tours crosses the network
The server applies what it knows, the client applies what only the page knows. Ineligible users receive an empty list and never download the engine.
RuleWhereExample value
AudienceServerRole is editor, plan is Team, signed up 14 days ago or less
Already doneServerSkip if the user completed the action the tour teaches, like already created a board
Show onceServerNever re-offer after completed or dismissed, unless the author publishes a new major version and opts in
Frequency capServerAt most 1 tour per session and 3 per week across all tours
Global opt-outServer"Don't show tips" in settings turns off all push tours; the checklist stays available
PriorityBothOnboarding 100, feature announcement 50, survey 10; highest eligible wins the slot
TriggerClientFirst visit to the board route, or the card.created event
Not busyClientNo 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.

Architecture. In the browser: the App with data-tour anchors and the Tour loader of 2 KB in the main bundle. The loader asks the Eligibility API, and if a tour is eligible it imports the Tour engine, a lazy chunk with four parts: the Runner state machine, the Anchor resolver with observers and a registry, the Step view with the dialog and overlay, and the Progress store with a mirror and a queue. The resolver finds targets in the App. On the server side: the Eligibility API with no-store, Definitions on a CDN that are immutable, the Progress API with idempotent writes, and Analytics and A/B testing receiving events.BrowserAppdata-tour anchorsTour loader2 KB, main bundleTour engine (lazy chunk)import()Runnerstate machineAnchor resolverobserver, registryStep viewdialog, overlayProgress storemirror, queueServersEligibility APIno-storeDefinitionsCDN, immutableProgress APIidempotent PUTAnalytics, A/Bevents
The boundary that matters most is the dashed lazy chunk: sessions with no tour stop at the loader. Inside it, only the step view knows about React; the runner and resolver are plain code.
PartResponsibilityWhy 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 engineKeeps the cost near zero for the 90 percent or more of sessions with no tour
Tour runnerThe state machine per tour: current step, waiting, showing, ending; applies the missing-target rulePure logic, framework-free, so it's unit-testable without a DOM
Anchor resolverRegistry lookups, MutationObserver wait, reveal functions, visibility check, iframe offsetsAll DOM fragility in one place, with one timeout policy
Positioner and overlayFloating UI placement plus the even-odd spotlight path; follows scroll and resizeReuses the tooltip guide's positioning; nothing tour-specific
Step viewThe step dialog: heading, body, progress, Back, Next, Close; focus and announcementsThe only React part; swappable for a different design system
Progress storeLocal mirror plus a retrying write queue with a forward-only mergeSteps never wait on the network
Guidance slotOne interruption at a time, by priority; shared with announcements and surveysStops teams' tours from stacking
Tour eventsEmits tour_step_shown and friends to the analytics SDKAnalytics 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.

Data flow in three columns: server stores, edge and APIs, and the browser. Definitions DB publishes to the CDN, which serves v4.json to the Tour engine. The Tour state DB sends progress to the Eligibility API, which sends the shortlist to the Tour loader; the loader imports the engine, and the engine drives the Step view. The Step view sends PUT step to the Progress API, which merges into the Tour state DB, and sends step events to Analytics. The Checklist worker, listening to domain events, marks items done in the Tour state DB.Definitions DBCDN: v4.jsonTour enginepublishfetch JSONTour state DBprogress, checklistEligibility APITour loaderprogressshortlistimport()Progress APIStep viewPUT stepmergeChecklist workerAnalyticsitem donestep events
Shared, immutable data flows from the CDN; personal data flows from 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?

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.

ts
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 };
Client-side entities. Definitions come from the CDN, progress from the eligibility API, and the live step lives in memory.
DataLives inCached how
Tour definition (versioned JSON)CDN, then memoryImmutable URL per version, max-age of a year
Eligible list and progressServer; mirrored in memory and localStorageno-store; mirror used only to resume instantly
Checklist items and done stateServer; derived from domain eventsFetched with eligibility, updated by push or refetch
Live step (element, rect, phase)Runner memory onlyNever saved; recomputed after reload
Global opt-outServer user settingsRead with eligibility

The HTTP API

http
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", ... }
One read on load, one idempotent write per step change.

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

ts
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>;
}
What app code sees: start and stop, anchors, reveal functions, lifecycle hooks, and events.

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?

Tour completion rate

Completed tours divided by started tours.

Pros

  • Easy to compute from tour events alone.

Cons

  • Rewards short, skippable tours; says nothing about whether users learned anything.
  • Selection bias: finishers differ from skippers.

Per-step funnel

For each step: shown, next, back, dismissed, target missing. Plot the drop between steps.

Pros

  • Finds the broken step: a cliff at step 3 is usually bad copy, a confusing required action, or a missing target.

Cons

  • Diagnoses the tour, but still can't show it causes activation.

Activation lift against a no-tour holdout

The A/B system splits eligible users: one arm gets the tour, the control arm gets nothing. Compare activation within 7 days.

Pros

  • The only measure that shows the tour causes the outcome.
  • Catches tours that hurt, by annoying users into leaving.

Cons

  • Needs enough traffic and a week or more per test.
  • Exposure must be logged carefully in both arms.

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.

A step funnel for the board tour, as horizontal bars. Step 1, Your workflow, is shown to 100 percent of starters. Step 2, Add a task, 78 percent. Step 3, Move it, 41 percent, a cliff. Step 4, Invite, 37 percent. Completed, 35 percent. A note says the step 3 drop includes 22 percent of starts that hit a missing target, so fix the anchor before the copy.1 Your workflow100%2 Add a task78%3 Move it41%4 Invite37%completed35%step 3: 22% of starts hit a missing targetfix the anchor before the copy
The funnel finds where people leave; the events say why. Here most of the cliff at step 3 is 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.

A loading timeline from 0 to 4 seconds for two sessions. Both load the app's JavaScript and data until about 1.4 seconds, and LCP happens at 1.6 seconds. Then both ask whether a tour is eligible. The session with no tour gets an empty list and stops. The eligible session then downloads the 25 KB engine around 2.6 to 3.5 seconds, and the first step shows at about 3.6 seconds, when its trigger fires.no toureligibleapp JS, data0 toursstopapp JS, data1 tourengine25 KB gzippedstepLCP 1.6 s0 s1 s2 s3 s4 s
Everything tour-related happens after LCP and off the critical path. The session with no tour pays one small request and nothing else.
  • 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: requestIdleCallback where it exists, a short setTimeout where 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: fixed or 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" with aria-labelledby pointing at its heading and aria-describedby at its body. No aria-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-describedby at 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 button with an accessible name like "Tip: Automations" and aria-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?

Think first

How does this work on a phone, where there's no room for a bubble next to a button?

Think first

Why not just buy Appcues or Pendo?

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-tour ids, 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next