@absolutejs/tour
v0.3.0-beta.4
Published
Element-level, cross-page product tour engine for AbsoluteJS apps. A serializable step protocol plus a spotlight positioning engine (pixel-accurate, follows late layout shifts) and a sessionStorage-backed controller that resumes across full-page navigatio
Readme
@absolutejs/tour
Element-level, cross-page product tour engine for AbsoluteJS apps.
A tour is described by a small, serializable protocol (steps with a target selector, route, placement, and copy) so it can live in code, in a database, or be authored in an admin UI — and rendered by the same engine. The engine gives you:
- Spotlight positioning — dims the page, highlights one real element, and stays pixel-accurate. It re-measures on a light interval so the highlight follows late layout shifts (a page loading its data and reflowing after the first measure), not just scroll/resize.
- Cross-page resume — in an MPA each page is its own mount, so moving between
steps is a full reload. The controller persists progress in
sessionStorageand resumes on the next page at the right step. - A shared controller — start/stop the tour from anywhere (a first-visit trigger, a "replay" button), all driving one overlay.
Vue 3 and vue-router are peer dependencies. The engine ships as composables; you render a tiny overlay component with your own styling (see below).
Install
bun add @absolutejs/tourThe protocol
import type { Tutorial } from "@absolutejs/tour";
const tour: Tutorial = {
slug: "portal-intro",
trigger: { firstVisitOnly: true, onRoutePrefix: "/portal" },
steps: [
{ title: "Welcome", body: "A quick tour.", placement: "center", route: "/dashboard" },
{
title: "Your command center",
body: "Your single best next step is always one click here.",
route: "/dashboard",
target: '[data-tour=\"hero\"]',
placement: "bottom",
},
// …intake, matches, network — each navigates and spotlights a real element
],
};Add data-tour="…" attributes to the elements you want to spotlight. A step
with no target (or one that can't be found) renders as a centered card.
Wiring it up
// shared controller (singleton per storage key)
import { useTourController } from "@absolutejs/tour";
const tour = useTourController("myapp.tour");
// auto-play once on first visit, then stamp your own "seen" marker
if (!account.tourSeenAt) tour.start();
// replay from anywhere (does not re-stamp — pass replay=true)
tour.start(true);Render an overlay component that consumes useSpotlight (style it however you
like — the engine only computes geometry):
<script setup lang="ts">
import { useSpotlight, useTourController } from "@absolutejs/tour";
import { PORTAL_TOUR_STEPS } from "./steps";
const controller = useTourController("myapp.tour");
const emit = defineEmits<{ close: [] }>();
const t = useSpotlight({
steps: () => PORTAL_TOUR_STEPS,
controller,
onClose: () => emit("close"),
});
</script>
<template>
<Teleport to="body">
<div v-if="t.active.value && t.step.value" class="tour-root">
<div class="tour-blocker" :class="{ dim: t.isCentered.value }"></div>
<div class="tour-spotlight" :style="t.spotlightStyle.value"></div>
<div
class="tour-tooltip"
:class="{ centered: t.isCentered.value }"
:style="t.isCentered.value ? {} : t.tooltipStyle.value"
>
<p>Step {{ t.index.value + 1 }} of {{ t.stepCount.value }}</p>
<h3>{{ t.step.value.title }}</h3>
<p>{{ t.step.value.body }}</p>
<button @click="t.skip">Skip</button>
<button v-if="!t.isFirst.value" @click="t.back">Back</button>
<button @click="t.next">{{ t.isLast.value ? "Done" : "Next" }}</button>
</div>
</div>
</Teleport>
</template>The host app owns: the data-tour anchors, the step content, and where the
"seen" marker is stored (so finishing a first-visit run stamps it; a replay
does not).
Step actions — demo the product, don't just point at it
Steps stay serializable, so a step references actions by name; the host
registers the handlers. onEnter actions run once the step is positioned
(sequentially, cancelled if the step changes mid-run); onExit actions run
when the step is left — cleanup/restore.
// The page that OWNS the surface registers its demo handler (setup):
import { useTourActions } from "@absolutejs/tour";
const actions = useTourActions();
const unregister = actions.register("matches.demo-swipe", async (ctx) => {
const direction = ctx.args.direction === "left" ? "left" : "right";
await swiper.value?.demoSwipe(direction); // ctx.signal aborts long demos
});
onBeforeUnmount(unregister);// The step invokes it — plain JSON, safe to store in a DB / author in an admin UI:
{
title: "Swipe or list",
route: "/portal/matches",
target: '[data-tour="match-view"]',
onEnter: [
{ action: "matches.demo-swipe", args: { direction: "right" } },
{ action: "wait", args: { ms: 700 } },
{ action: "matches.demo-swipe", args: { direction: "left" } },
],
}Built-ins (no host code needed): click, scroll, wait — each takes an
optional selector (default: the step's target). Unknown action names warn
and skip so a tutorial authored against an unmounted page degrades instead of
breaking the tour. Handlers receive { step, target, args, index, signal,
next, back, stop } — a handler can drive the tour itself (e.g. auto-advance
when its demo finishes).
Demo data — tour an empty account for free
A tour must be able to show a data-backed surface (matches, pipeline) to a
viewer who has no data yet — a fresh signup, an unsubscribed user — without
the host paying to source anything. useTourDemo swaps in a constant,
fully-typed sample dataset while the tour plays and passes the real data
through untouched otherwise:
import { useTourDemo, useTourController } from "@absolutejs/tour";
const controller = useTourController("myapp.tour");
const { data: matches, isDemo } = useTourDemo({
controller,
demo: DEMO_MATCHES, // typed PartnerMatch[] — same shape the surface renders
live: () => realMatches.value,
mode: () => tutorial.value?.dataMode, // optional per-tutorial override
});Resolution is per TourDataMode: "auto" (default) shows the viewer's real
data when they have it — so a member with sourced matches is toured on their
literal matches — and the sample when they don't; "demo" / "live" force
one side (Tutorial.dataMode carries the choice in the serialized tutorial).
Badge the surface when isDemo is true so sample data is never mistaken for
real.
Funnel events — see where viewers bail
Pass onEvent to useSpotlight and every lifecycle moment lands in your
analytics: tour_started, step_viewed, step_completed,
step_target_missing, action_failed, tour_completed, and — the one that
matters — tour_skipped, carrying the exact stepIndex, stepTitle,
target, and route (the screen the viewer was on when they'd had enough)
plus a reason distinguishing the Skip button from Escape.
useSpotlight({
steps, controller, onClose,
tutorialSlug: () => activeTutorial.value?.slug,
onEvent: (event) => analytics.track(event),
});Branching & readiness — showIf / skipIf / waitFor
Conditions mirror actions: serializable refs resolved by name against a
registry (useTourConditions), with element and media built in.
useTourConditions().register("hasDeals", () => dealCount.value > 0);{
title: "Your pipeline",
showIf: [{ condition: "hasDeals" }], // all must hold, else skipped
skipIf: [{ condition: "media", args: { query: "(max-width: 640px)" } }],
waitFor: { selector: ".pipeline-board", timeoutMs: 5000 }, // hold until ready
}Skipped steps are hopped in the direction of travel; if everything ahead is skipped the tour completes cleanly.
Mobile variants
Below mobileQuery (default (max-width: 640px)) a step's mobile block
overrides its target/placement/copy — or skips it where the anchor doesn't
exist on small screens:
{ target: '[data-tour="toolbar-btn"]', mobile: { target: '[data-tour="menu"]', placement: "top" } }
{ target: '[data-tour="desktop-panel"]', mobile: { skip: true } }CTA buttons
cta: { label: "Try it now", actions: [{ action: "click" }] } renders a
button in the card (host template: v-if="step.cta" → @click="runCta");
it runs the refs through the action registry and advances unless
advance: false.
Auto-play gate — stop nagging people
useTourGate owns the auto-play decision across MANY tutorials: dismissal
caps (trigger.maxDismissals, default 2 — after that, manual replay only),
oncePerSession, priority when several tutorials match a page, audience
showIf predicates, and role matching. State persists in localStorage.
const gate = useTourGate({ roles: () => viewer.roles });
const tutorial = gate.pick(publishedTutorials, route.path);
if (tutorial) {
gate.recordAutoPlay(tutorial.slug ?? "");
controller.start();
}
// from your onEvent sink:
// tour_skipped → gate.recordDismissal(slug)
// tour_completed → gate.recordCompletion(slug)Checklist — "getting started" engine
Typed tasks + completion persistence + progress math; the host renders the
panel. completeForTutorial(slug) checks off tasks tied to a tutorial (wire
it to the tour_completed event).
const checklist = useTourChecklist({
id: "onboarding",
items: () => [
{ id: "intake", title: "Finish your intake", href: "/portal/intake" },
{ id: "tour", title: "Take the tour", tutorialSlug: "portal-intro" },
],
});
// checklist.items → [{...item, done}], checklist.progress → {done,total,percent}Hotspots — always-on help beacons
Persistent pulsing beacons on tricky UI (independent of any tour) that open
an explainer card on click. once: true hides a beacon after it's been
opened; dismissals persist.
const spots = useTourHotspots({
hotspots: () => [
{ id: "trust-fit", target: '[data-tour="trust-fit"]', title: "Trust & Fit", body: "…" },
],
enabled: () => !tourController.active.value,
});
// render spots.beacons (beacon per target) and spots.card (open card) yourselfLicense
Business Source License 1.1 — see LICENSE. Converts to Apache 2.0 on the Change Date.
