Product Tour
Free8 onboarding variants: modal (free) plus slideshow, tooltip, spotlight, beacon, checklist, progress steps and announcement (Pro). Portal-based overlay with keyboard navigation.
import { SvProductTourComponent } from '@styloviz/product-tour';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Spotlight from a step index (Pro)
<sv-product-tour-pro variant="spotlight" [steps]="steps" [active]="touring" [stepIndex]="0" (stepChange)="onStep($event)" />
A step whose target lives on another page
<!-- The tour never navigates: it asks, and you route. Give the step a route and the tour emits (routeRequest) the moment that step becomes current, then WAITS for its targetSelector to render on the new page and anchors to it. Without the wait the target does not exist yet when the step arrives, and the step loses its spotlight for the rest of the tour. --> <sv-product-tour-pro variant="spotlight" [steps]="steps" [(active)]="touring" [awaitTargetMs]="3000" (routeRequest)="router.navigateByUrl($event.route)" (targetMissing)="skipStep($event)" />
Resuming a tour after a reload
<!-- The tour stores its progress in a versioned envelope under persistKey: the step by ID (never an index - inserting a step shifts every index after it), the checklist ticks, and whether the user completed or dismissed it. It never decides whether to show itself: handle (progressRestored) and let your own flag, cohort or "seen" table decide what [active] should be. --> <sv-product-tour-pro tourId="onboarding" [tourVersion]="2" persistKey="sv-tour-onboarding" resume="step" [(active)]="touring" [steps]="steps" (progressRestored)="onProgress($event)" />
What a restored progress tells you
onProgress(e: SvTourProgressRestored) { // 'completed' or 'dismissed' is the "don't show this again" that // nothing used to record. They are different facts: someone who // skipped may want it back, someone who finished will not. if (e.progress.status === 'completed') this.touring.set(false); // Why the tour is on the step it is on: // 'resumed' - back where they were // 'policy' - resume="restart", or nothing stored // 'step-missing' - that step no longer exists, so it restarted // 'version-changed' - tourVersion moved on; the position is void // 'declined' - resume="never" this.analytics.track('tour_resume', { reason: e.reason, at: e.index }); }
The steps behind it
steps = [
{ id: 'welcome', title: 'Welcome', content: 'Here is your dashboard.' },
{
id: 'invoices',
title: 'Your invoices',
content: 'Everything you have billed.',
route: '/invoices', // asked for, not navigated to
targetSelector: '#invoice-table', // waited for, then anchored
},
];When to use
- Onboarding new users or introducing a new feature with guided steps.
- You want spotlight/tooltip/beacon/checklist styles over real UI.
- For a single announcement, an Alert Banner or Modal is enough.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Saved state
- Stored kinds
- sv-product-tour/progress
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| steps | TourStep[] | [] | Step definitions. |
| variant | TourVariant | 'modal' | Deprecated, and has no effect: the free tour renders the `modal` variant only, so this input has a single value. |
| active | boolean (two-way) | false | Two-way: controls tour visibility. Set to `true` to launch the tour. |
| stepIndex | number (two-way) | 0 | Two-way: current step index. |
| nextLabel | string | 'Next' | Text of the button that advances to the next step; the last step shows `doneLabel` instead. |
| prevLabel | string | 'Back' | Text of the button that returns to the previous step. |
| skipLabel | string | 'Skip tour' | Text of the button that dismisses the tour before it is finished. |
| doneLabel | string | 'Done' | Text of the advance button on the last step, which finishes the tour. |
| allowSkip | boolean | true | Show the skip / close controls. |
| showStepCount | boolean | true | Show the "step X of N" counter. |
| showProgress | boolean | true | Show the dot progress indicator. |
| tourId | string | '' | Identity of this tour DEFINITION — not of the component instance. Required for persistence, and stored inside the blob as well as being part of the key, because the key is the host's and keys get reused. A progress blob from last quarter's onboarding tour, read by this quarter's, restores a `stepId` that means nothing. |
| tourVersion | number | 1 | Version of this tour's STEPS. Bump it when the steps change. STORED PROGRESS FROM ANOTHER VERSION IS NOT ADOPTABLE. The stored step id may still exist and mean something different, or the order may have changed — either way the position is a guess, so a version change restarts the tour and says so. |
| expectedVersion | number | null | null | The version the HOST says its UI is at. `null` (default) = do not check. ONLY THE HOST KNOWS THIS, which is why it is an input and not an inference. A tour authored against v1 of a screen, running against v3, points at controls that have moved or gone — and nothing inside a component library can detect that from the outside. A mismatch is REPORTED through `(schemaStale)` and does not stop the tour: deciding whether a returning user sees it depends on things the component cannot see. The runtime half — `(targetMissing)` for a step whose target is not there — is Pro-only, because the free tier's one variant (`modal`) anchors to nothing. |
| persistKey | string | '' | Where `localStorage` keeps this tour's progress. Empty = no persistence. |
| resume | 'step' | 'restart' | 'never' | 'step' | What a stored, unfinished tour does on the next visit. | Value | Meaning | |-------------|----------------------------------------------------------| | `'step'` | resume on the stored step (first step if it is gone) | | `'restart'` | always from the first step | | `'never'` | restore the ticks, not the position | "resume, restart or exit" was undefined, which is the finding. It is a per-tour decision — a five-step onboarding wants resuming, a two-step feature announcement does not — so it is an input rather than a rule. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| stepChange | number | Fires with the new step index whenever navigation occurs. |
| complete | void | Fires when the last step is confirmed (Next on final step). |
| skip | void | Fires when the user explicitly skips the tour. |
| progressRestored | SvTourProgressRestored | Stored progress was found and applied. THE TOUR DOES NOT DECIDE WHETHER TO SHOW ITSELF, for the same reason it does not navigate: whether a returning user sees it depends on a feature flag, a cohort, or the host's own "seen" table. What it owes is the RECORD — status, step, ticks — and this event, so the host's `[active]` can be an informed one. A `status` of `'completed'` or `'dismissed'` here is the "don't show this again" the audit said nothing recorded. |
| schemaStale | SvTourSchemaStale | `tourVersion` and `expectedVersion` disagree: these steps were authored against a different version of the UI they point at. |
| routeRequest | TourRouteRequest | A step with a `route` became current — the host should navigate there. Fires on arrival, including the step the tour opens on, and once per arrival. The tour never navigates itself: it cannot know whether the app uses `@angular/router`, whether the jump needs params or passes a guard, or whether the host would rather refuse. |
| activeChange | boolean | Emits the new `active` whenever it changes — the output half of `[(active)]`. |
| stepIndexChange | number | Emits the new `stepIndex` whenever it changes — the output half of `[(stepIndex)]`. |
<sv-product-tour-pro>
Also in this family — SvProductTourProComponent from @styloviz-pro/product-tour.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Item identity & owned state
- Restorable state
- completedIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Saved state
- Stored kinds
- sv-product-tour/progress
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| steps | TourStep[] | [] | Step definitions. |
| variant | TourVariant | 'modal' | Visual variant: modal, slideshow, tooltip, spotlight, beacon, checklist, progress-steps or announcement. The free `sv-product-tour` renders the modal only. |
| active | boolean (two-way) | false | Two-way: controls tour visibility. Set to `true` to launch the tour. |
| stepIndex | number (two-way) | 0 | Two-way: current step index. |
| tourTitle | string | 'Get Started' | Heading shown in the tour panel and checklist header. |
| tourSubtitle | string | 'Complete the steps below to set up you… | Supporting line under `tourTitle` in the checklist header. |
| nextLabel | string | 'Next' | Text of the button that advances to the next step; the last step shows `doneLabel` instead. |
| prevLabel | string | 'Back' | Text of the button that returns to the previous step. |
| skipLabel | string | 'Skip tour' | Text of the button that dismisses the tour before it is finished. |
| doneLabel | string | 'Done' | Text of the advance button on the last step, which finishes the tour. |
| allowSkip | boolean | true | Show skip button on modal / tooltip / slideshow / spotlight variants. |
| showStepCount | boolean | true | Show step X of N counter. |
| showProgress | boolean | true | Show dot / bar progress indicator. |
| overlayOpacity | number | 0.55 | Backdrop opacity for spotlight & modal variants. Range 0–1. Default 0.55. |
| spotlightPadding | number | 10 | Padding (px) added around the target element in the spotlight cutout. Default 10. |
| awaitTargetMs | number | 3000 | How long to wait (ms) for a step's `targetSelector` to appear before giving up and emitting `(targetMissing)`. THE WAIT IS WHAT MAKES A CROSS-ROUTE STEP WORK AT ALL. `(routeRequest)` fires, the host navigates, and the new page renders some frames later — so at the instant the step becomes current its target does NOT exist. Without the wait the tour saw "no target", cleared the anchor, and never looked again: the step silently lost its spotlight for the rest of the tour, and the card explained an element nothing was pointing at. `0` disables waiting — a step whose target is absent reports it at once. |
| tourId | string | '' | Identity of this tour DEFINITION — not of the component instance. Required for persistence, and stored inside the blob as well as being part of the key, because the key is the host's and keys get reused. A progress blob from last quarter's onboarding tour, read by this quarter's, restores a `stepId` that means nothing. |
| tourVersion | number | 1 | Version of this tour's STEPS. Bump it when the steps change. STORED PROGRESS FROM ANOTHER VERSION IS NOT ADOPTABLE. The stored step id may still exist and mean something different, or the order may have changed — either way the position is a guess, so a version change restarts the tour and says so. |
| expectedVersion | number | null | null | The version the HOST says its UI is at. `null` (default) = do not check. ONLY THE HOST KNOWS THIS, which is why it is an input and not an inference. A tour authored against v1 of a screen, running against v3, points at controls that have moved or gone — and nothing inside a component library can detect that from the outside. A mismatch is REPORTED through `(schemaStale)` and does not stop the tour: deciding whether a returning user sees it depends on things the component cannot see. The runtime half is already covered — `(targetMissing)` fires for a step whose target is not there. |
| persistKey | string | '' | Where `localStorage` keeps this tour's progress. Empty = no persistence. |
| resume | 'step' | 'restart' | 'never' | 'step' | What a stored, unfinished tour does on the next visit. | Value | Meaning | |-------------|----------------------------------------------------------| | `'step'` | resume on the stored step (first step if it is gone) | | `'restart'` | always from the first step | | `'never'` | restore the ticks, not the position | "resume, restart or exit" was undefined, which is the finding. It is a per-tour decision — a five-step onboarding wants resuming, a two-step feature announcement does not — so it is an input rather than a rule. |
| completedIds | ReadonlySet<string> (two-way) | new Set() | Checklist: IDs of completed steps (two-way). A `model` because this is the one piece of tour state that must outlive the page: a checklist that cannot be restored shows a returning user the items they already finished, and `initiallyCompleted` on the steps was the only way in — which means the host had to rebuild its step array to record a tick. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| stepChange | number | Fires with the new step index whenever navigation occurs. |
| complete | void | Fires when the last step is confirmed (Next on final step). |
| skip | void | Fires when the user explicitly skips the tour. |
| progressRestored | SvTourProgressRestored | Stored progress was found and applied. THE TOUR DOES NOT DECIDE WHETHER TO SHOW ITSELF, for the same reason it does not navigate: whether a returning user sees it depends on a feature flag, a cohort, or the host's own "seen" table. What it owes is the RECORD — status, step, ticks — and this event, so the host's `[active]` can be an informed one. A `status` of `'completed'` or `'dismissed'` here is the "don't show this again" the audit said nothing recorded. |
| schemaStale | SvTourSchemaStale | `tourVersion` and `expectedVersion` disagree: these steps were authored against a different version of the UI they point at. |
| itemToggle | string | Checklist: fires with the step `id` whenever an item is toggled. Carries the id regardless of the new checked state. |
| routeRequest | TourRouteRequest | A step with a `route` became current — the host should navigate there. Fires on arrival, including the step the tour opens on, and once per arrival. The tour never navigates itself: see `TourStep.route`. |
| targetMissing | TourStep | A step's `targetSelector` never appeared within `awaitTargetMs` (or the step's own `awaitMs`) — in the `tooltip`, `spotlight` and `beacon` variants, the three that anchor to a target. REPORTED, NOT SWALLOWED. The old behaviour was to clear the anchor and carry on, which leaves the user reading a card about an element that is not on the page — the host is the only party that can decide whether to skip the step, end the tour, or accept an unanchored card. |
| activeChange | boolean | Emits the new `active` whenever it changes — the output half of `[(active)]`. |
| stepIndexChange | number | Emits the new `stepIndex` whenever it changes — the output half of `[(stepIndex)]`. |
| completedIdsChange | ReadonlySet<string> | Emits the new `completedIds` whenever it changes — the output half of `[(completedIds)]`. |
Customization & theming
- Spotlight, tooltip, beacon and checklist styles.
- Configurable padding and opacity.
- Step progress and skip controls.
- Progress that survives a reload: `persistKey` + `tourId` store the step BY ID (never an index — inserting a step shifts every index after it), the checklist ticks and whether the tour was completed or dismissed, inside the same versioned envelope six other families use. `resume` decides what an interrupted tour does — `step`, `restart` or `never` — and `(progressRestored)` reports which happened and why. The tour never opens itself: that stays the host's `[active]`.
- Schema staleness: `[tourVersion]` versions the steps and `[expectedVersion]` is what the host says its UI is at — a mismatch emits `(schemaStale)`, because only the host can know a screen was rebuilt.
- Responsive: the tooltip / beacon cards cap their width to the viewport, so they stay fully on-screen even on the narrowest phones.
- Route-aware orchestration: `TourStep.route` + `(routeRequest)` ask the host to navigate (the tour never routes itself), `[awaitTargetMs]` bounds the wait for the new page to render the step's target, and `(targetMissing)` reports a target that never came. A step with no anchor at all centres its card and drops the arrow rather than pointing at whatever the previous step was on.
Best practices
- Do: Pick the variant from how much of the screen you are entitled to take, not from how it looks. FOUR of the eight dim the page, trap Tab and declare `aria-modal` — `modal`, `slideshow`, `tooltip`, `spotlight` — and the claim is honest because the overlay stack marks the background inert. The other four are ambient and take nothing: `progress-steps` (a bar and a card over a live page — it was documented as modal-like and never was), `checklist`, `announcement`, and `beacon`, whose popover is a `role="dialog"` without `aria-modal` because a labelled popup is not a claim on the page. Mixing the two models in one onboarding flow means the keyboard contract changes under the user mid-tour.
- Do: Keep tours short (3–5 steps) and always offer a Skip.
- Do: Persist completion so users don't see the same tour repeatedly.
- Tip: Anchor steps to stable elements so the spotlight lands accurately.
- Do: Handle `(routeRequest)` if any step sets `route` — the tour asks, you navigate. It deliberately does not touch the router: only your app knows whether the jump needs params, passes a guard, or should be refused. It then waits for the step's `targetSelector` to appear on the new page and anchors to it.
- Do: Give the tour a `tourId` and a `persistKey` if it is longer than a couple of steps — a tour interrupted by a reload had no defined behaviour, and the record is what "don't show this again" reads. Bump `[tourVersion]` whenever you change the steps: a stored position from another version is not adoptable, because the stored step id may still exist and mean something different.
- Do: Handle `(targetMissing)` rather than letting a step point at nothing. It fires when a `targetSelector` never appears within `awaitTargetMs` (default 3000) — the only party that can decide between skipping the step, ending the tour and accepting an unanchored card is you.
Accessibility
- Four variants are modal: **modal, slideshow, spotlight, tooltip**. Each dims the page, is `role="dialog"` + `aria-modal="true"`, moves focus into the card on open, traps Tab, marks the rest of the page `inert`, and restores focus on close.
- **checklist, announcement, beacon and progress-steps are ambient** — no backdrop, no trap, no inertness. The page behind them stays usable, which is the point of choosing them, and focus stays where the user left it. checklist, progress-steps and announcement are named `role="region"` landmarks (labelled by `tourTitle`, or the announcement's title) so a screen-reader user can find the surface that appeared, and progress-steps marks the current step `aria-current="step"`. ⚠️ `progress-steps` USED TO TRAP TAB, and this documentation claimed it was modal: it renders a top bar and a card and dims nothing, so trapping made it a keyboard trap in reverse — a mouse user could reach the page it describes and a keyboard user could not.
- Arrow keys move between steps, except when focus is in a field that has its own use for them (an input, textarea, select, contenteditable, or a listbox/combobox/menu/slider role) — the caret wins there, not the tour.
- Esc skips the tour when `allowSkip` is on, and closes a beacon card when one is open. The tour defers Esc to anything opened OVER it, so a select inside the card closes first.
Feedback on Product Tour
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.