Checklist, Hotspots & the Overlay
One guided run is not an onboarding system. useTourChecklist keeps a persistent getting-started panel — typed tasks, completion persistence, progress math — useTourHotspots puts always-on beacons on tricky UI, and useSpotlight turns the serialized steps into pixel-accurate geometry for an overlay you render and style yourself.
#Three Help Surfaces
The three surfaces split the same way: the composable owns discovery, state, persistence, and geometry; the host renders and styles. They also share one storageKey namespace (default 'absolute.tour'), so a product's tour, checklist, and hotspots persist side by side.
| Surface | Spotlight tour | Checklist | Hotspots |
|---|---|---|---|
| Shape | Linear steps across pages | Task list with progress | Per-element beacons |
| Lifetime | One run per trigger | Until dismissed | Always on |
| Persists | Progress in sessionStorage | Completions in localStorage | Dismissals in localStorage |
| Best for | First-visit walkthrough | Activation milestones | Tricky UI explainers |
#Getting-started Checklist
The stickiest pattern in this category: a persistent panel with N tasks, a progress bar, and each task launching a tour or a deep link. useTourChecklist is the logic only — the host renders the panel and decides what launching a task means. State lives in localStorage under storageKey.checklist.id, so completions survive sessions.
import { useTourChecklist } from '@absolutejs/tour';
const checklist = useTourChecklist({
// Identity of this checklist — its persistence bucket in
// localStorage (namespaced by storageKey, default 'absolute.tour').
id: 'onboarding',
// The tasks — a getter so the list can be reactive.
items: () => [
{ href: '/portal/intake', id: 'intake', title: 'Finish your intake' },
// Completing this tutorial checks the task off (see
// completeForTutorial below).
{ id: 'tour', title: 'Take the tour', tutorialSlug: 'portal-intro' },
{
description: 'Get your first three matches reviewed.',
href: '/portal/matches',
id: 'matches',
title: 'Review your matches'
}
]
});
// The host renders the panel:
// checklist.items.value → [{ ...item, done }]
// checklist.progress.value → { done, total, percent }Each TourChecklistItem is plain data:
#Completion & Progress
Tasks complete three ways: the host calls complete(id) directly, the viewer finishes a tutorial tied to the task, or the host re-derives completion from product state. The tutorial path is the interesting one — the engine's funnel events carry the tutorialSlug, so one line in the onEvent sink closes the loop.
// Wire tutorial completion into the checklist from the engine's funnel
// events — finishing 'portal-intro' checks off every task tied to it.
useSpotlight({
controller,
onClose,
onEvent: (event) => {
if (event.name === 'tour_completed') {
checklist.completeForTutorial(event.tutorialSlug ?? '');
}
},
steps: () => PORTAL_TOUR_STEPS,
tutorialSlug: () => 'portal-intro'
});
// Manual task management:
checklist.complete('intake');
checklist.uncomplete('intake');
checklist.isDone('intake'); // boolean
// Dismissal persists (an ISO timestamp) until restored:
checklist.dismiss(); // checklist.dismissed.value → true
checklist.restore();
// Wipe completions AND the dismissal:
checklist.reset();The activation loop end to end:
checklist.items.value → [{ ...item, done }]tour_completed → checklist.completeForTutorial(slug)#Always-on Hotspots
Some UI needs explaining every time, not once — a trust score, a weekly refresh cadence. useTourHotspots keeps persistent pulsing beacons on those elements, independent of any linear tour, and opens an explainer card on click. Same machinery as the tour's beacon and card, but per-element and permanent.
import { useTourController, useTourHotspots } from '@absolutejs/tour';
const tourController = useTourController('myapp.tour');
// Persistent pulsing beacons on tricky UI — independent of any linear
// tour — that open an explainer card on click.
const spots = useTourHotspots({
// Master switch: hide beacons while a tour is playing. Default on.
enabled: () => !tourController.active.value,
// The hotspots for the current page — a getter so it can be reactive.
hotspots: () => [
{
body: 'How aligned this investor is with your goals.',
id: 'trust-fit',
placement: 'right',
target: "[data-tour='trust-fit']",
title: 'Trust & Fit'
},
{
body: 'Matches refresh every Monday morning.',
id: 'refresh',
// Hide the beacon permanently once it has been opened.
once: true,
target: "[data-tour='refresh']",
title: 'Weekly refresh'
}
]
});
// spots.beacons.value → [{ hotspot, style }] — one beacon per target
// spots.card.value → { hotspot, style } | null — the open card
// spots.open(id) / spots.close() / spots.dismiss(id) / spots.restoreAll()enabled getter is the master switch (default on). Wire it to !tourController.active.value so pulsing beacons never compete with an active spotlight.#Beacons & Card
The composable measures every eligible target on scroll, resize, and a light 250ms interval, and hands the host ready-to-render position styles. A target with no visible box (a collapsed drawer, a hidden panel) is skipped rather than beaconed at 0×0.
dismiss(id) and a once hotspot's first open both persist to localStorage — the beacon stays gone across sessions until restoreAll(). Useful for a "restore tips" switch in settings.#Rendering the Overlay
useSpotlight is the engine behind everything the other pages configure: it walks the steps, navigates cross-page, runs actions, evaluates branches, and emits funnel events — but it renders nothing. The host teleports a small overlay to body and styles it however it likes; 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({
controller,
// Below this query, steps' mobile blocks win. Default shown.
mobileQuery: '(max-width: 640px)',
onClose: () => emit('close'),
// Hold positioning (and navigation!) until the host has resolved
// WHICH steps to play — an async tutorial fetch on a cross-page
// resume. The engine re-locates when this flips true.
ready: () => tutorialLoaded.value,
steps: () => PORTAL_TOUR_STEPS,
// Per-tutorial theme → CSS custom properties on the overlay.
theme: () => activeTutorial.value?.theme
});
</script>
<template>
<Teleport to="body">
<div
v-if="t.active.value && t.step.value"
class="tour-root"
:style="t.themeVars.value"
>
<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>Escape skips (reason "escape"), ArrowRight / Enter advance, and ArrowLeft goes back — no host wiring needed.#Overlay Surface
Positioning works from the card's measured size: the engine flips to the roomier side when the preferred placement can't fit, then clamps both axes so the card — and its Skip / Next controls — can never leave the viewport. After each step it re-measures on short settle delays and a light 150ms interval, so the highlight follows late layout shifts; a target that collapses mid-step degrades to the centered card instead of a 0×0 spotlight.