Basic Usage
A tour is a config object plus a handle you drive from your own UI. The handle never renders anything: it publishes a current snapshot, and your card (or refine's ore-tour) renders it.
import { createTour } from '@vielzeug/beacon';
const tour = createTour({
id: 'product-tour',
steps: [
{ id: 'welcome', title: 'Welcome', description: 'See what changed in this release.' },
{ id: 'nav', target: '[data-tour="nav"]', title: 'Find everything', placement: 'bottom-start' },
{ id: 'share', target: '[data-tour="share"]', title: 'Share the board', placement: 'bottom-end' },
],
autoStart: true,
});
// Readable<T> from @vielzeug/ripple: the listener takes no arguments;
// read .value inside it.
const unsubscribe = tour.current.subscribe(() => {
const current = tour.current.value;
if (!current.step) {
hideCard();
return;
}
renderCard({
anchor: current.rect, // viewport rect of the target, null for centered steps
index: current.stepIndex,
placement: current.placement,
presentation: current.presentation,
title: current.step.title,
total: current.totalSteps,
});
});
// Wire your card's controls back to the handle.
cardEl.addEventListener('next', () => tour.next());
cardEl.addEventListener('back', () => tour.back());
cardEl.addEventListener('skip', () => tour.skip());
// On teardown:
unsubscribe();
tour.dispose();start() is a no-op when the stored record says this user already completed the tour, so calling it on every page load is safe.
Steps and Targets
A step without a target centers (intro/outro copy). A step with one anchors, and beacon waits for the target to exist before publishing the step — a tour can walk through lazily-mounted routes without any coordination code.
const tour = createTour({
id: 'deep-tour',
steps: [
// Selector: resolved with document.querySelector on activation.
{ id: 'sidebar', target: '[data-tour="sidebar"]', title: 'Your libraries' },
// Element: a ref your framework already holds.
{ id: 'editor', target: editorEl, title: 'The editor' },
// Rect: geometry with no element, such as a canvas region.
{ id: 'chart', target: { height: 120, width: 240, x: 400, y: 300 }, title: 'This trend' },
// Resolver: computed on every activation, and on scroll/resize while the step is active.
{ id: 'selection', target: () => window.getSelection()?.getRangeAt(0).getBoundingClientRect() ?? null, title: 'Your selection' },
],
// Give up waiting after 4s and skip the step instead of hanging the tour.
targetTimeoutMs: 4000,
});With the default targetTimeoutMs: Infinity, a step whose target never mounts simply waits: the tour resumes the moment the user navigates to it. Set 0 to publish immediately with a null rect instead of waiting.
Persisting Progress
Without storage, progress lives in memory for the handle's lifetime only. Any object matching BeaconStorage makes it durable — values are JSON, keys are namespaced under @vielzeug/beacon:.
import type { BeaconStorage } from '@vielzeug/beacon';
const storage: BeaconStorage = {
get: (key) => JSON.parse(localStorage.getItem(key) ?? 'null'),
remove: (key) => localStorage.removeItem(key),
set: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
};
const tour = createTour({ id: 'product-tour', steps, storage });Async storage works too: get may return a promise, and the tour boots as soon as it resolves. A set that rejects is reported through tap() as a warning event, never thrown.
Records are validated on read, so a hand-edited or stale record falls back to a fresh tour instead of crashing.
Re-offering a finished tour
Bump version when the product changes and every user who finished the old tour becomes eligible again — users mid-tour on the old version keep their progress.
const tour = createTour({
id: 'product-tour',
steps: newSteps,
version: 2, // stored records at version 1 no longer block the tour
});reset() clears the persisted record for the current user, which is what a "Replay tour" or "Reset onboarding" control should call before start().
Driving a Refine Presentation
@vielzeug/refine ships ore-tour (a dialog with step footer, progress, and focus handling) and ore-spotlight (a decorative cutout that dims everything but the target). A beacon handle satisfies ore-tour's TourController shape, so one call wires both directions:
import type { OreTourElement } from '@vielzeug/refine/tour';
const card = document.querySelector<OreTourElement>('#tour-card');
const tour = createTour({ id: 'product-tour', presentation: 'auto', steps });
// bind() renders `current` into the card's props and routes the card's
// next/back/skip/finish/dismiss actions back into the handle.
const unbind = card.bind(tour);
tour.start();
// On teardown:
unbind();
tour.dispose();presentation: 'auto' maps to a popover on wide viewports and a bottom sheet on narrow ones; ore-tour makes that switch itself and freezes the choice while the card is open, so a mid-step resize never rebuilds the card. Beacon keeps current.rect fresh on scroll, resize, and layout changes, so the anchored card follows the layout with no app-side listeners.
Apps that render their own markup read current directly instead (see Basic Usage).
Coachmarks
A coachmark is one hint anchored to one target, shown the first time the target appears and dismissed permanently after. Use it for the "explain this button once" moment a tour would be too heavy for — or as the follow-up a tour hands off to.
import { createCoachmark } from '@vielzeug/beacon';
const share = createCoachmark({
id: 'share-hint',
target: '[data-tour="share"]',
title: 'Share this board',
description: 'Send a link that opens straight into this view.',
storage,
});
// Render from `current` — the same single-step snapshot a tour publishes.
share.current.subscribe(() => console.log(share.current.value.step !== null, share.current.value.rect));
// The target may never appear on this page; show() waits for it.
share.show();
// "Got it" button:
share.dismiss(); // records it as seen; never shows again for this versionhide() closes the hint without recording it, so it can appear again later; dismiss() is permanent until reset() or a version bump.
Events and Analytics
tap() observes everything the engine considers notable. It returns an unsubscribe function and accepts an AbortSignal, so one controller can tear down every subscription.
const controller = new AbortController();
tour.tap((event) => {
switch (event.type) {
case 'step:start':
analytics.track('tour_step', { id: tour.id, step: event.step.id });
break;
case 'tour:complete':
analytics.track('tour_complete', { id: tour.id, reason: event.reason });
break;
case 'target-missing':
// A step's target never mounted: your selector drifted from the DOM.
console.warn(event.message);
break;
case 'warning':
console.warn(event.message, event.error);
break;
}
}, { signal: controller.signal });target-missing is the event to alert on: it means a step was skipped because its target did not appear within targetTimeoutMs, which is exactly how stale tours announce themselves.
Testing
Beacon runs without a rendering layer, so plain DOM tests are enough. The default in-memory storage keeps each handle's progress isolated, and jsdom's getBoundingClientRect() returns zero-sized rects, which is fine for step-order assertions.
import { createTour } from '@vielzeug/beacon';
import { expect, test } from 'vitest';
test('walks steps in order and completes', () => {
const tour = createTour({
id: 'test-tour',
steps: [{ id: 'a' }, { id: 'b' }],
targetTimeoutMs: 0, // publish immediately even without targets
});
tour.start();
expect(tour.current.value.step?.id).toBe('a');
tour.next();
expect(tour.current.value.step?.id).toBe('b');
tour.next(); // past the last step finishes
expect(tour.status.value).toBe('completed');
});For persistence assertions, pass a Map-backed BeaconStorage and a second handle with the same id to check it resumes where the first stopped.
Framework Integration
Beacon is framework-neutral: bridge the Readable snapshot with whatever primitive your framework already has.
import { useEffect, useState } from 'react';
import { createTour } from '@vielzeug/beacon';
function useTourState(tour: ReturnType<typeof createTour>) {
const [current, setCurrent] = useState(tour.current.value);
useEffect(() => tour.current.subscribe(() => setCurrent(tour.current.value)), [tour]);
return current;
}<script setup lang="ts">
import { onScopeDispose, shallowRef } from 'vue';
import { createTour } from '@vielzeug/beacon';
const current = shallowRef(tour.current.value);
const unsubscribe = tour.current.subscribe(() => (current.value = tour.current.value));
onScopeDispose(unsubscribe);
</script><script lang="ts">
import { onDestroy } from 'svelte';
import { readable } from 'svelte/store';
const current = readable(tour.current.value, (set) => {
set(tour.current.value);
return tour.current.subscribe(() => set(tour.current.value));
});
onDestroy(() => tour.dispose());
</script>Working with Other Vielzeug Libraries
- Refine:
ore-tourandore-spotlightare the intended presentation layer; see Driving a Refine Presentation. - Orbit:
current.rectmatches orbit'sRectshape, so a custom card positions withcomputePosition({ getBoundingClientRect: () => current.rect ?? ZERO_RECT }, floatingEl, middleware). - Vault: any vault store becomes a
BeaconStoragewith a six-line bridge (see theBeaconStoragedocs in API Reference). - Ripple: every handle exposes ripple
Readables;createRipple().isBatchingObservablesemantics apply when you subscribe from effects.
Best Practices
- Give every tour and coachmark a stable
id— it is the storage key; treat renames as data migrations. - Keep tour configs in their own modules as plain data checked with
satisfies BeaconTourConfig, so steps stay reviewable data, not component code. - Prefer
data-tour="..."attributes as target selectors: they survive class renames and read as intent. - Set a finite
targetTimeoutMsin production and alert ontarget-missingto catch stale selectors. - Call
reset()beforestart()for replay controls; never delete storage keys by hand. - Bump
versionwhen a tour's meaning changes, not when its copy is typo-fixed. - Dispose handles on teardown;
tap()subscriptions with a sharedAbortSignalclean up in one call.