Branching, Gates & Funnel Events
One tour rarely fits every viewer. Serializable conditions branch steps with showIf / skipIf and hold them with waitFor; useTourGate owns the auto-play decision across many tutorials (dismissal caps, once-per-session, priority, audience); and structured funnel events tell you exactly where viewers bail — down to the step, route, and whether they hit Skip or Escape.
#Condition Registry
Conditions mirror actions: a step (or a trigger rule) references a predicate by name in plain JSON, and the host registers it on useTourConditions() — a keyed singleton, default key 'absolute.tour'. Built-ins cover the DOM-shaped checks; anything product-shaped ("hasDeals", "isSubscribed") comes from the host.
| Built-in | Args | True when |
|---|---|---|
element | { selector: '.pipeline-board' } | True when document.querySelector finds the selector. |
media | { query: '(max-width: 640px)' } | True when the media query currently matches. |
import { useTourConditions } from '@absolutejs/tour';
// Conditions mirror actions: serializable refs resolved BY NAME against
// a keyed-singleton registry. Built-ins cover the DOM-shaped checks;
// anything product-shaped comes from the host.
const conditions = useTourConditions(); // default key 'absolute.tour'
const unregister = conditions.register(
'hasDeals',
() => dealCount.value > 0
);
conditions.registerAll({
isSubscribed: () => Boolean(viewer.value?.subscription),
onTrial: () => viewer.value?.plan === 'trial'
});
// Built-ins — no registration needed:
// { condition: 'element', args: { selector: '.pipeline-board' } }
// { condition: 'media', args: { query: '(max-width: 640px)' } }showIf on a missing predicate hides the step rather than breaking, and a skipIf on one doesn't skip. Throwing predicates are contained the same way.#showIf & skipIf
A step is skipped when any skipIf predicate holds, when not all showIf predicates hold, or when its mobile.skip flag applies below the mobile query. Skipped steps are hopped in the direction of travel — going forward hops forward, going back hops back (and falls forward again if everything behind is skipped).
const step = {
body: 'Every deal, one board.',
route: '/portal/pipeline',
// ALL showIf predicates must hold, or the step is skipped.
showIf: [{ condition: 'hasDeals' }],
// ANY skipIf predicate holding skips the step.
skipIf: [
{ args: { query: '(max-width: 640px)' }, condition: 'media' }
],
target: '.pipeline-board',
title: 'Your pipeline',
// Hold the step until the app is ready for it: a selector that must
// exist (and have a visible box), or a registered condition that
// must return true. Past timeoutMs (default 3500) the step shows
// anyway rather than stalling the tour.
waitFor: { selector: '.pipeline-board', timeoutMs: 5000 }
};
// waitFor can also poll a registered condition (120ms interval):
// waitFor: { condition: { condition: 'hasDeals' }, timeoutMs: 4000 }tour_completed with reason "remaining-skipped" and closes.#waitFor Readiness
Branching decides whether a step shows; waitFor decides when. It holds the step until the app is ready for it — a panel rendered after a data fetch, a host predicate flipped true — instead of spotlighting a half-loaded surface.
#Funnel Events
Pass onEvent to useSpotlight and every lifecycle moment lands in your analytics. The engine emits; the host sinks the events into whatever analytics it runs.
import { useSpotlight } from '@absolutejs/tour';
const t = useSpotlight({
controller,
onClose,
// Every lifecycle moment lands in your analytics.
onEvent: (event) => analytics.track(event.name, event),
steps: () => activeTutorial.value?.steps ?? [],
// Stamped on every event so tutorials don't blur together.
tutorialSlug: () => activeTutorial.value?.slug
});
// A tour_skipped event — the one that matters — carries the exact
// screen the viewer was on when they'd had enough:
// {
// at: '2026-07-12T17:03:11.302Z',
// isReplay: false,
// name: 'tour_skipped',
// reason: 'escape', // Skip button → 'skip'
// route: '/portal/matches', // window.location.pathname at fire time
// stepCount: 7,
// stepIndex: 4,
// stepTitle: 'Swipe or list',
// target: "[data-tour='match-view']",
// tutorialSlug: 'portal-intro'
// }Every TourEvent carries the full context:
#Auto-play Gate
Home-grown tours always break here first: one "seen" flag can't express "stop nagging after two skips", "at most once per session", or "when three tutorials match this page, play the important one". useTourGate owns that bookkeeping — state persists in localStorage because dismissals must survive sessions — and evaluates the trigger rules; the host just asks pick() and records what happened.
import { useTourController, useTourGate } from '@absolutejs/tour';
const controller = useTourController('myapp.tour');
// The auto-play decision, centralized. Dismissal counts persist in
// localStorage — they must survive sessions.
const gate = useTourGate({ roles: () => viewer.roles });
// The one tutorial to auto-play now: eligible candidates on this route,
// highest trigger.priority wins (stable on ties).
const tutorial = gate.pick(publishedTutorials, route.path);
if (tutorial) {
gate.recordAutoPlay(tutorial.slug ?? '');
controller.start();
}
// From your onEvent sink:
const onEvent = (event) => {
const slug = activeTutorial.value?.slug ?? '';
if (event.name === 'tour_skipped') gate.recordDismissal(slug);
if (event.name === 'tour_completed') gate.recordCompletion(slug);
};
// Manual replays bypass the gate entirely — it governs auto-play only.
controller.start(true);#Trigger Rules
A tutorial's trigger block describes when it should auto-play; a tutorial without one never auto-plays. All rules are serializable, so they store and author like the rest of the protocol:
import type { Tutorial } from '@absolutejs/tour';
const tutorial: Tutorial = {
slug: 'portal-intro',
steps: PORTAL_TOUR_STEPS,
trigger: {
// Auto-play once per viewer, on their first visit.
firstVisitOnly: true,
// Stop auto-playing after this many skips (default 2). Manual
// replay stays available.
maxDismissals: 2,
// Only auto-play when the current path starts with this prefix.
onRoutePrefix: '/portal',
// Auto-play at most once per browser session.
oncePerSession: true,
// When several tutorials match a page, highest priority wins.
priority: 10,
// Restrict auto-play to these role slugs (at least one must
// match the gate's roles getter).
roles: ['member'],
// Audience predicates — all must hold for auto-play.
showIf: [{ condition: 'isSubscribed' }]
}
};#Wiring Events to the Gate
The gate and the funnel events close the loop: the gate decides, the engine reports, and the report feeds the next decision.
const tutorial = gate.pick(publishedTutorials, route.path);gate.recordAutoPlay(tutorial.slug ?? ''); controller.start();tour_skipped → gate.recordDismissal(slug)tour_completed → gate.recordCompletion(slug)