When users go off-script
resumeAt, handoffRoutes, pauseRoutes.
Why this exists
Because the guide points at real controls, users operate them before the step that describes them. When that navigates, every anchor on the old page disappears at once, and a naive tour dies on a screen the user is no longer looking at.
These fields are the difference between a tour that survives real behaviour and one that only works if you follow it exactly.
The four fields
| Prop | Type | Default | Description |
|---|---|---|---|
resumeAt | { pathname; stepIndex }[] | — | They got ahead of the guide — catch up. Forward only. |
handoffRoutes | { pathname; flowId }[] | — | Another guide covers this route — switch to it. |
pauseRoutes | string[] | — | Nobody covers this route — sleep, keep their place. |
scope | string | — | Not a route at all — a tab or stage. Sleep when it is not in front. |
resumeAt
The user clicks “Create manually” while the guide is still describing the cards above it. Landing on the form resumes at the first form step.
resumeAt: [{ pathname: "/questions/new", stepIndex: 6 }]handoffRoutes
Some routes are a different way of doing the same job, not a wrong turn. Writing a question by hand and generating one with AI are siblings. Choosing the second mid-tour should switch guides, not punish the user.
handoffRoutes: [{ pathname: "/questions/ai", flowId: "create-with-ai" }]pauseRoutes
The tour goes dormant: no overlay, no anchor hunting, no “we lost the step” message. Returning to a covered route resumes on the same step.
pauseRoutes: ["/settings", "/billing"]Dynamic routes
resumeAt, handoffRoutes and pauseRoutes all take patterns as well as exact pathnames. A detail route cannot be listed one id at a time, and leaving it uncovered is not neutral: the tour keeps running onto a page holding none of its anchors, and ends as anchor-missing — which your analytics then reports as a broken anchor.
pauseRoutes: [
"/settings", // exact, and still the common case
"/projects/:slug", // :name matches exactly one segment
"/docs/*", // * matches the rest
]| Prop | Type | Default | Description |
|---|---|---|---|
/settings | exact | — | No : or * anywhere, so it is compared as a string. Every flow written before patterns existed behaves exactly as it did. |
:name | one segment | — | /projects/:slug matches /projects/acme and not /projects/acme/keys. One segment on purpose: a flow pausing on a detail page must not also pause on everything beneath it, where a different guide may be taking over. |
* | the rest | — | /docs/* matches /docs/install and /docs/install/next, but not /docs itself — otherwise a flow launched from an index would pause on the page it started on. |
Leaving the page a step lives on
None of the fields above are needed for the commonest case of all: the user presses the browser Back button, or follows a link, while the guide is mid-flow on another page.
cairnkit records the pathname each step became active on. When a step’s anchor goes missing it compares the two:
- Missing on the page it lives on — the element is genuinely gone. The tour ends and says so.
- Missing because you left that page — the tour goes dormant and keeps your place. Coming back wakes it on the same step.
The Back button on the step card disappears while the previous step is unreachable from here, since pressing it would land nowhere. A previous step with an onEnter is exempt — that hook exists to restore the modal or panel its anchor lives in, so absence right now proves nothing.
scope
Everything above is keyed on the pathname. Tabs break that: two guides live at one URL, and switching between them changes nothing the router can see. The running guide keeps pointing at a panel that just unmounted.
A flow declares which part of the screen it belongs to, and the component that owns that state declares which part is in front:
// the flow
defineFlow({ id: "invite-by-email", scope: "email", /* … */ });
defineFlow({ id: "invite-by-link", scope: "link", /* … */ });
// the component that owns the tabs
function InvitePage() {
const [tab, setTab] = useState<"email" | "link">("email");
useTourScope(tab);
// …
}A guide whose scope is not in front goes dormant, exactly as pauseRoutes does for a route it does not cover — so switching back picks up on the step you left.
const { flow } = useActiveTour();
const launched = useRef<string | null>(null);
useEffect(() => {
if (flow?.id === launched.current) return;
launched.current = flow?.id ?? null;
if (flow?.scope) setTab(flow.scope);
}, [flow]);Keyed on the flow changing rather than re-asserted every render, or it would drag the user back each time they switched tabs mid-tour. Switching away is allowed — it is what sends the guide to sleep.
While a guide is dormant, a TourLauncher for a different flow becomes visible — that is how the user reaches the guide covering the tab they just opened. The dormant flow’s own launcher stays hidden, since restarting it would throw away the progress it is holding.
Rules and precedence
Evaluated in this order, in one place:
- out of scope — the wrong tab is in front, so nothing else matters
- handoff — another guide owns this route
- pause — nobody covers it
- resume — the user got ahead
Any branch in your UI needs steps, a resumeAt, a handoffRoutes, a pauseRoutes entry, or a scope. This is the failure mode neither the lint check nor the browser audit can catch, because every anchor genuinely exists — just not on the page, or the tab, the user chose.