ngx-tour-guide
v0.2.0
Published
Angular 12+ bindings for tour-guide-core: step-by-step product tour / onboarding guide with spotlight, popover and keyboard navigation.
Maintainers
Readme
ngx-tour-guide
Angular bindings for tour-guide-core:
step-by-step product tours / onboarding with a spotlight and a popover.
- Angular 12 → 22 with a single package (built with Angular 12 tooling, Ivy partial compilation). RxJS 6 or 7. No CDK.
- Declarative (
tourStepdirective) or programmatic (TourService.start(steps)) - Custom step content via
ng-template; string content is sanitized withDomSanitizer - Onboarding built in:
onceruns a tour one time per person (pluggable store, versioned keys),startWhenholds it until the screen is ready — a getter or anObservable - Auto placement, scroll / resize tracking, keyboard navigation, focus trap, theming and every option come from the core — see its README for details
Install
npm i ngx-tour-guideQuick start
1. Import the module (and optionally configure it) in AppModule:
import { NgxTourGuideModule } from 'ngx-tour-guide';
@NgModule({
imports: [
NgxTourGuideModule.forRoot({
labels: { next: 'Next', prev: 'Back', skip: 'Skip', done: 'Done' },
showProgressBar: true,
}),
],
})
export class AppModule {}Standalone apps (Angular 14+):
bootstrapApplication(App, { providers: [provideTourGuide({ showProgressBar: true })] });
@Component({ imports: [NgxTourGuideModule], ... })
export class App {}2. Mark elements with the tourStep directive and start the tour:
import { TourService } from 'ngx-tour-guide';
@Component({
template: `
<input tourStep="search" tourTitle="Search" tourContent="Find anything." tourOrder="1" />
<button tourStep="save" tourTitle="Save" tourContent="Don't forget to <b>save</b>." tourOrder="2" tourPlacement="top">
Save
</button>
<button (click)="tour.start()">Start tour</button>
`,
})
export class PageComponent {
constructor(public tour: TourService) {}
}tour.start() collects every tourStep on the page, sorts them by tourOrder and shows
the first step. The overlay is created by the core; nothing else goes into templates.
Directive inputs
| Input | Type | Description |
| ---------------------- | --------------------------------------- | ---------------------------------------------------- |
| tourStep | string | Step id (required) |
| tourTitle | string | |
| tourContent | string | Text or HTML (sanitized) |
| tourTemplate | TemplateRef<TourStepTemplateContext> | Custom content, replaces tourContent |
| tourOrder | number \| string | Sort order, default 0 (stable, then DOM order) |
| tourPlacement | 'top' \| 'bottom' \| 'left' \| 'right' \| 'auto' | Default from config (auto) |
| tourName | string | Group steps into separate tours (start('name')) |
| tourPadding | number \| string | Space around the element, px |
| tourAllowInteraction | boolean | Let the user click / type in the highlighted element |
Programmatic tours
tour.start([
{ id: 'welcome', template: this.welcomeTpl }, // no target -> centered
{ id: 'search', allowInteraction: true }, // uses the tourStep="search" directive
{
id: 'profile',
title: 'Your profile',
content: 'Lives inside the account menu.',
target: '#menu-profile', // CSS selector, element or () => element
placement: 'left',
beforeShow: () => this.menuOpen = true, // may return a Promise
afterHide: () => this.menuOpen = false,
},
{
id: 'orders',
target: '#orders-table',
beforeShow: () => this.router.navigate(['/orders']),
waitForTarget: 3000, // poll up to 3 s for the element
},
]);How the target is resolved for a step:
target(selector / element / function), polled up towaitForTargetms;- otherwise the
tourStepdirective with the sameid; - otherwise the popover is centered on screen.
Explicit steps are merged with the matching directive, so { id: 'search' } inherits the
title / content / placement declared in the template.
TourService
| Member | Description |
| ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| start(stepsOrName?, { startAt?, once?, ifBusy? }) | start() default tour, start('name') named tour, start([...]) explicit. Resolves true when it opened |
| startWhen(condition, stepsOrName?, options?) | Starts once condition turns true (a getter or an Observable); returns a cancel function |
| hasSeen(once) / markSeen(once) / forgetSeen(once) | Read and write the once flags directly |
| next() / prev() / goTo(i) / end(reason?) | Navigation. end reasons: 'complete' \| 'skip' \| 'programmatic' |
| state$, isActive$, currentStep$ | Observables (state$ emits a TourState snapshot on every change) |
| isActive, currentStep, currentIndex, currentTarget, steps, total, isFirst, isLast | Synchronous getters |
| events | Observable<TourEvent> — start, step, end |
| core | The underlying tour-guide-core Tour instance |
Call start() after the view exists (a click handler, ngAfterViewInit, ...): directives
register themselves in ngOnInit.
Show a tour only once
tour.start(steps, { once: 'onboarding' });It runs the first time and is skipped afterwards. The flag is written when the person
ends the tour — finishing it, closing it or skipping it. Ending it from code (end(), a
route change, a component being destroyed) does not count, so nobody loses an onboarding
they never got to read.
Bump version when the content changes and everyone sees the new tour once:
tour.start(steps, { once: { key: 'onboarding', version: 2 } });hasSeen() / markSeen() / forgetSeen() read and write those flags directly: a "Replay
the tour" menu item calls forgetSeen before start, a "Don't show me tips" setting calls
markSeen.
Flags live in localStorage by default — that is per browser, not per account. See
Configuration for a store of your own.
Wait for the screen to be ready
waitForTarget waits a moment for an element. startWhen waits — for as long as it
takes — for the person: the detail pane that only exists once a row is picked, the save
bar that appears once there are unsaved changes. Steps pointing at those have nothing to
highlight while the tour runs on page load, so they fall back to a popover centered on an
empty screen. Split the tour into stages instead, one per condition:
export class OrdersComponent implements OnInit, OnDestroy {
private cancelTour: () => void = () => {};
constructor(private tour: TourService) {}
ngOnInit(): void {
this.cancelTour = this.tour.startWhen(
() => !!this.selectedOrder, // a getter, checked every `pollInterval` ms
() => this.detailSteps(),
{ once: { key: 'orders.detail', version: 2 }, delay: 300 },
);
}
ngOnDestroy(): void {
this.cancelTour();
}
}Pass an Observable instead and nothing is polled — the app already says when the
value changed:
this.cancelTour = this.tour.startWhen(this.selectedOrder$, () => this.detailSteps());Anything observable works: a store selector, form.valueChanges, a filtered router.events.
Apps on Angular 16+ can drive it from a signal either way:
// an observable view of the signal — still no polling
this.cancelTour = this.tour.startWhen(toObservable(this.selected), () => this.detailSteps());
// or read the signal in a getter, which is polled
this.cancelTour = this.tour.startWhen(() => !!this.selected(), () => this.detailSteps());Two things to get right:
- Call the returned function in
ngOnDestroy. A wait without atimeoutotherwise outlives the component that started it. - Pass the steps as a function. It runs when the tour finally opens, so labels are
translated with the current language and a
TemplateRefis read while the view that owns it is still alive — one captured up front may already be destroyed by then.
| Option | Default | Description |
| --------------------------- | ------- | ---------------------------------------------------------------------------------- |
| delay | 0 | Wait this long after the condition turns true, letting the app render |
| pollInterval | 200 | How often a getter condition is checked. Ignored for an Observable |
| timeout | 0 | Give up after this many ms; 0 waits indefinitely |
| stillValid | — | Checked again right before opening; false cancels this attempt and keeps waiting |
| once, ifBusy, startAt | — | Same as start() |
A tour scheduled this way queues behind one that is already running (ifBusy: 'queue'),
so an earlier stage is never cut off mid-sentence. start() keeps its old behaviour and
restarts; 'skip' gives up instead.
Custom content
<ng-template #welcomeTpl let-step let-index="index" let-total="total" let-tour="tour">
<h3>Welcome!</h3>
<p>Step {{ index + 1 }} of {{ total }}</p>
<button (click)="tour.goTo(total - 1)">Jump to the end</button>
</ng-template>The context is { $implicit: step, step, index, total, tour }.
Configuration (forRoot / provideTourGuide)
| Option | Default | Description |
| ---------------------- | ---------------------- | ------------------------------------------------- |
| labels | English | next, prev, skip, done, close, progress(current, total) |
| placement | 'auto' | bottom → top → right → left, first side that fits |
| padding | 8 | Space around the highlighted element, px |
| spotlightRadius | 8 | Corner radius of the hole, px |
| backdropColor | rgba(0, 0, 0, 0.55) | |
| popoverWidth | 340 | Max width, px |
| zIndex | 10000 | |
| keyboard | true | ← → navigate, Esc closes |
| closeOnBackdropClick | false | |
| showPrev / showSkip / showClose | true | |
| showProgress | true | "1 / 5" counter |
| showProgressBar | false | |
| allowInteraction | false | Default for TourStep.allowInteraction |
| scrollIntoView | true | Scroll the target into view before showing: centered, or top-aligned when taller than the viewport, no scroll when already visible. Use CSS scroll-margin-top on targets hidden by sticky headers |
| scrollBehavior | 'smooth' | |
| waitForTarget | 1000 | Default polling timeout, ms |
| seenStore | localStorage | Where once flags are kept. null disables once. Every access is guarded, so it is a no-op during SSR and when site data is blocked |
| seenPrefix | 'tour-guide:' | Prefix for keys written to seenStore |
localStorage is per browser, not per account: two people sharing a machine means the
second one never sees the onboarding, and nothing follows anyone to another device. Keep
the flags where the rest of the user's profile lives:
provideTourGuide({
labels: { next: 'Next', prev: 'Back', skip: 'Skip', done: 'Done' },
seenStore: {
has: (key) => profile.seenTours.indexOf(key) !== -1,
mark: (key) => profile.markTourSeen(key), // fire-and-forget; return value ignored
forget: (key) => profile.forgetTour(key),
},
});provideTourGuide() takes a plain value, so when the store needs injected services, provide
the token itself:
providers: [
{
provide: TOUR_CONFIG,
useFactory: (account: AccountService) => ({ seenStore: account.tourStore }),
deps: [AccountService],
},
];createLocalStorageSeenStore(getStorage?), createMemorySeenStore() (per page load) and
createNoopSeenStore() (never remembers) are re-exported from this package for the common
cases, along with the TourSeenStore type and seenKey().
Theming
All colors and sizes are CSS custom properties on .tour-overlay (global styles, e.g.
styles.scss):
.tour-overlay {
--tour-bg: #1f2937;
--tour-fg: #f9fafb;
--tour-muted: #9ca3af;
--tour-border: rgba(255, 255, 255, 0.15);
--tour-primary: #22c55e;
--tour-primary-fg: #052e16;
--tour-radius: 16px;
--tour-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
--tour-ring: rgba(255, 255, 255, 0.3);
--tour-font: inherit;
}Class names are stable (tour-popover, tour-title, tour-content, tour-btn,
tour-btn--primary, tour-arrow, tour-spotlight, tour-backdrop, ...) if you need
deeper overrides.
Compatibility
| Angular | Status |
| ------- | ------------------------------------------------------------- |
| 12 – 22 | Supported (compiled with Angular 12, Ivy partial; depends on tour-guide-core) |
| ≤ 11 | Not supported: those versions need View Engine packages, which Angular 16+ can no longer consume |
Browsers: clip-path: path() is used for the spotlight — Chrome/Edge 88+, Firefox 97+,
Safari 13.1+ (no IE11).
License
MIT
