NbWalkthrough is a guided product tour (coach-marks): it dims the screen, spotlights one element at a time, and shows a popover with a description and controls. Use it to teach first-time users the shape of the application, where the navigation lives, what the views are for, where the common controls sit.
The tour is described by a plain, versioned object and driven by the useWalkthrough composable. The component is a thin renderer over that state machine, which is what lets the same tour be started from a template, a help menu, or a router guard.
Live demo
Press Start the tour. Use Tab to move between the controls, and Escape to skip.
Defining a tour
A walkthrough is data, not markup: an id, a version, and a list of steps. Keep it in its own module so it can be reviewed, translated and versioned like any other content.
// src/onboarding/intro.tour.ts
import type { IWalkthrough } from '@nubisco/ui'
export const introTour: IWalkthrough = {
id: 'app-intro',
version: 1,
steps: [
{
title: 'Welcome',
body: 'A quick look at where everything lives. It takes about a minute.',
},
{
target: 'sidebar-nav',
title: 'Navigation',
body: 'Every section of the application is reachable from here.',
placement: 'right',
},
{
target: 'view-switcher',
title: 'Views',
body: 'Switch between the table, the board and the calendar.',
padding: 12,
},
{
target: 'toolbar-actions',
title: 'Actions',
body: 'Actions apply to whatever you have selected.',
},
],
}A step with no target is an intro or outro: the popover is centred on the viewport with no spotlight.
Tagging targets
Reference elements by a stable id rather than a CSS selector. The v-nb-tour-step directive stamps data-nb-tour-step on the element, and the id travels with it through refactors that would break a structural selector.
<template>
<NbSidebarMenu v-nb-tour-step="'sidebar-nav'">
<!-- … -->
</NbSidebarMenu>
</template>A step's target also accepts a CSS selector string, an element, a template ref, or a getter returning one. Strings are resolved as a tour-step id first and only then as a selector, so an id never loses to an element of the same name.
const byTourStepId = { target: 'sidebar-nav' } // preferred
const bySelector = { target: '#summary-total' }
const byGetter = { target: () => tableRef.value?.$el }
const byTemplateRef = { target: headerRef }Steps whose target is not in the DOM (a collapsed panel, a route the user has not visited) are skipped over in the direction the user is travelling rather than showing an empty spotlight.
Running it once per version
The most common wiring: auto-start on mount, and let the stored version decide.
<template>
<NbWalkthrough :walkthrough="introTour" auto-start />
</template>
<script setup lang="ts">
import { introTour } from '@/onboarding/intro.tour'
</script>On finish or skip, the tour records { version, outcome, completedAt } under its id. It auto-starts only when nothing is stored for the current version, so:
- a returning user does not see it again;
- bumping
versionto2re-shows it to everyone, including people who completed version 1; - a user who already saw a newer version is not shown an older one.
Replaying it from a help menu
For "Show me around" in a help menu, own the controller yourself and pass it in. The component will not auto-start a controller it does not own, so the host stays in charge of when the tour runs.
<template>
<NbMenuItem @click="tour.restart()">Show me around</NbMenuItem>
<NbWalkthrough :controller="tour" @finish="onFinish" />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useWalkthrough } from '@nubisco/ui'
import { introTour } from '@/onboarding/intro.tour'
const tour = useWalkthrough(introTour)
// Auto-start on first visit; `restart()` above always replays.
onMounted(() => tour.maybeAutoStart())
function onFinish() {
console.log('tour complete')
}
</script>start() runs the tour from step 0 without touching storage. restart() clears the stored record first, so a replayed tour will auto-start again on the next visit only if that is what you want; call finish() afterwards if it should not.
Pluggable persistence
The default adapter writes to localStorage under nb:walkthrough:<id>. Swap it for anything that satisfies IWalkthroughStorage; reads may be async, so a backend-backed store needs no change at the call sites.
import type { IWalkthroughStorage } from '@nubisco/ui'
const apiStorage: IWalkthroughStorage = {
get: (id) => api.get(`/me/onboarding/${id}`).then((r) => r.data ?? null),
set: (id, record) => api.put(`/me/onboarding/${id}`, record),
remove: (id) => api.delete(`/me/onboarding/${id}`),
}
const tour = useWalkthrough(introTour, { storage: apiStorage })Two adapters ship with the library:
| Factory | Behaviour |
|---|---|
createLocalStorageWalkthroughStorage(prefix?) | Persists to localStorage. Degrades to "never seen" when storage is blocked (private mode, sandboxed iframe) rather than throwing. |
createMemoryWalkthroughStorage(seed?) | In-memory only. Useful in tests and in demos that should replay on every load. |
Custom wording and localisation
Every string the tour renders is overridable through labels, so you can soften the defaults to fit your product's voice.
<template>
<NbWalkthrough
:walkthrough="introTour"
:labels="{
back: 'Previous',
next: 'Continue',
skip: 'Not now',
done: 'Finish',
dialog: 'Getting started',
progress: (current, total) => `${current} of ${total}`,
}"
/>
</template>The library ships no translated strings of its own, so a localised app passes the same object from its translation function:
const labels = {
back: t('tour.back'),
next: t('tour.next'),
skip: t('tour.skip'),
done: t('tour.done'),
dialog: t('tour.dialog'),
progress: (current: number, total: number) =>
t('tour.progress', { current, total }),
}Custom step content
The step slot replaces the body for rich content; actions replaces the whole button row when you need different controls.
<template>
<NbWalkthrough :walkthrough="introTour">
<template #step="{ step, index, total }">
<p>{{ step.body }}</p>
<NbProgressBar :value="((index + 1) / total) * 100" />
</template>
</NbWalkthrough>
</template>Accessibility
- The popover is a
role="dialog"witharia-modal="true". Focus moves into it when the tour starts, so a screen reader announces the title and body before the controls. - Tab is trapped inside the popover: the page behind the scrim is not interactive, and letting focus walk out of the dialog would strand the user on invisible controls.
- Escape skips the tour.
- Focus returns to whatever was focused before the tour started, so a tour launched from a help menu returns you to the menu.
- The step counter is
aria-live="polite", so advancing announces the new position. - Under
prefers-reduced-motion: reduce, the popover animation is dropped and scrolling a target into view becomes an instant jump instead of a smooth scroll.
Defining a tour in an SFC block
An SFC <walkthrough> custom block co-located with the component it describes is appealing DX, but it is not the supported interface. The object and composable API above is the portable one: it works in plain TypeScript, in tests, and in apps that assemble tours at runtime. If co-location is worth it for your codebase, add a Vite plugin that compiles the custom block into exactly the IWalkthrough object shown above, so it stays sugar over the supported API rather than a second implementation with its own behaviour.