a11y-tour-guide
v1.0.0
Published
A framework-agnostic, accessible guided tour for the web.
Maintainers
Readme
a11y-tour-guide
a11y-tour-guide adds accessible, keyboard-friendly product tours to a web page.
It uses plain browser APIs, so it works with vanilla JavaScript and with
frameworks such as React, Vue, and Svelte.
The library manages the tour dialog, focus, keyboard controls, target highlighting, translations, right-to-left layouts, and missing targets.
Explore the project
The repository demo is organized to make the most important behavior easy to verify before the implementation detail gets in the way:
- Guided Proof demo starts only on a user action and walks through focus, keyboard, and recovery checks.
- API reference holds the complete step, controller, event, label, and theme contract.
- Interaction laboratory isolates advanced states such as missing targets, guards, async setup, modeless behavior, and skins.
- Basic example is a small release-handoff orientation that imports the built package output.
Installation
Install the project dependencies when working with this repository:
npm install
npm run devWhen using the published package in another project:
npm install a11y-tour-guide
pnpm add a11y-tour-guide
yarn add a11y-tour-guideBasic use
Import the library and its styles, create a tour, then start it from a button.
import { createTour } from "a11y-tour-guide";
import "a11y-tour-guide/styles.css";
const tour = createTour({
steps: [
{
id: "welcome",
target: "#welcome",
title: "Welcome",
content: "This is the first stop in the tour."
},
{
id: "settings",
target: "#settings",
title: "Settings",
content: "Change your preferences here."
}
]
});
document.querySelector("#start-tour").addEventListener("click", () => {
void tour.start();
});Each step needs a unique id, a target, a title, and some content.
HTML structure
The page markup stays semantic and application-owned. A tour needs stable
target elements and a user-operated control that calls start():
<button id="start-tour" type="button">Start tour</button>
<main>
<h1 id="welcome">Welcome</h1>
<section id="settings" aria-labelledby="settings-title">
<h2 id="settings-title">Settings</h2>
<p>Change your preferences here.</p>
</section>
</main>The library generates the dialog, overlay, highlight, live status region, and optional checklist checkbox when the tour starts. Do not hard-code those generated elements in your page markup.
CSS skins
The default stylesheet is still the easiest option. It includes the structural CSS and the classic skin:
import "a11y-tour-guide/styles.css";For a custom skin setup, import the base CSS once, import one or more theme
files, then set the theme option. The generated tour wrapper receives the
namespaced data-a11y-tour-theme attribute:
import "a11y-tour-guide/base.css";
import "a11y-tour-guide/themes/console.css";
import "a11y-tour-guide/themes/sticky-note.css";
const tour = createTour({
theme: "console",
steps
});Ready-to-use skins:
| Skin | Import | Theme value |
| --- | --- | --- |
| Classic | a11y-tour-guide/themes/classic.css | classic |
| Basic | a11y-tour-guide/themes/basic.css | basic |
| Brutalist | a11y-tour-guide/themes/brutalist.css | brutalist |
| Blueprint | a11y-tour-guide/themes/blueprint.css | blueprint |
| Sticky note | a11y-tour-guide/themes/sticky-note.css | sticky-note |
| Console | a11y-tour-guide/themes/console.css | console |
| Spotlight | a11y-tour-guide/themes/spotlight.css | spotlight |
| Checklist | a11y-tour-guide/themes/checklist.css | checklist |
| Glass | a11y-tour-guide/themes/glass.css | glass |
| Compact Pro | a11y-tour-guide/themes/compact-pro.css | compact-pro |
| High contrast | a11y-tour-guide/themes/high-contrast.css | high-contrast |
Use basic when you want the least opinionated starting point for a CMS,
white-label product, or site builder integration. It keeps the component
variables and accessible focus/touch defaults, but avoids decorative framing,
heavy shadows, custom layout treatments, and pseudo-element styling.
Legacy values and imports still work: dark maps to console,
soft-light maps to sticky-note, and compact maps to compact-pro.
Unknown theme values fall back to classic.
The documented public CSS variables are:
| Variable | Purpose |
| --- | --- |
| --a11y-tour-color-surface | Dialog surface color |
| --a11y-tour-color-text | Main text color |
| --a11y-tour-color-muted | Secondary text color |
| --a11y-tour-color-accent | Primary action and theme accent |
| --a11y-tour-color-border | Default border color |
| --a11y-tour-color-overlay | Modal overlay color |
| --a11y-tour-radius | Base corner radius |
| --a11y-tour-shadow | Dialog elevation |
| --a11y-tour-font-family | Dialog font stack |
| --a11y-tour-z-index | Stacking fallback when the root z-index is customized in CSS |
Override tokens after importing the theme CSS:
.product-onboarding {
--a11y-tour-color-accent: #005fcc;
--a11y-tour-radius: 0.75rem;
--a11y-tour-shadow: 0 1rem 2rem rgb(0 0 0 / 0.22);
}Each theme also uses private --_tour-* variables internally. Treat those as
implementation details; prefer the public variables above for app overrides.
Theme QA notes:
- Keyboard: the interaction laboratory's skin radios are native controls, and tour buttons stay reachable.
- Focus: buttons, checklist inputs, dialogs, and laboratory skin cards keep visible focus.
- Screen readers: title, content, progress text, and live updates remain in the dialog contract.
- Motion: highlight, overlay, and decorative transitions stop for reduced motion.
- Forced colors: dialogs, buttons, selected cards, and highlights use visible borders.
- RTL and mobile: theme spacing uses logical properties and remains usable at 320px.
Generated selectors and attributes:
| Selector or attribute | Purpose |
| --- | --- |
| .a11y-tour-root | Fixed-position generated tour wrapper. |
| .a11y-tour-overlay | Modal backdrop, present only for modal tours. |
| .a11y-tour-dialog | Labelled dialog surface that receives focus. |
| .a11y-tour-highlight | Visual target frame with aria-hidden="true". |
| .a11y-tour-live | Polite status region for step and close announcements. |
| .a11y-tour-button | Generated close, skip, previous, and next controls. |
| .a11y-tour-checklist-input | Optional native checkbox for checklist mode. |
| data-a11y-tour-theme | Theme selector applied to the generated root. |
Step options
| Option | What it does |
| --- | --- |
| id | A unique name for the step. You can also use it with goTo(). |
| target | The element to highlight. Use a CSS selector, an element, or a function that returns an element. |
| title | The heading shown in the tour dialog. |
| content | The main text shown in the tour dialog. |
| placement | Preferred dialog position: "top", "right", "bottom", "left", or "auto". |
| locales | Only include this step for specific languages, such as ["en", "fr"]. |
| beforeShow | Run a function before the step appears. It may be asynchronous. |
| canAdvance | Return false to stop the user moving to the next step. It may be asynchronous. |
| canAdvanceMessage | Announces why the user cannot move on when canAdvance returns false. |
Different kinds of targets
Use a selector for an element that already exists:
{
id: "profile",
target: "#profile-button",
title: "Your profile",
content: "Open your account settings here."
}Use an element directly:
{
id: "search",
target: document.querySelector("#search"),
title: "Search",
content: "Search the site from here."
}Use a function for content that may appear later:
{
id: "results",
target: () => document.querySelector(".search-results"),
title: "Results",
content: "Your latest results appear here."
}If a target is missing or hidden while a step is being shown, the tour makes one polite status announcement that names each consecutively unavailable target, then identifies the displayed fallback step and its progress. This avoids losing the skip reason when the fallback step is rendered.
Refresh after a framework render
Use refresh() after your framework has committed a replacement for the
currently highlighted target. Prefer a selector or a function target for this
case: an element target is a reference to that exact DOM node and cannot find
its replacement.
// Run after the component has rendered its updated DOM.
const refreshed = tour.refresh();
if (!refreshed) {
// The application decides whether to retry, show another view, or close the tour.
}For example, call it from a React useLayoutEffect, Vue nextTick, or the
equivalent post-render hook only when the current target may have changed.
refresh() does not run beforeShow, navigate to another step, scroll the
page, move focus, or announce anything when it succeeds. It resolves one
target, reads layout to reposition the dialog and highlight, then reconnects
the target observer.
If the target is unavailable, refresh() returns false, hides the stale
highlight, disconnects its observer, and keeps the current step open. It
announces that unavailable state once until a later refresh finds a target;
the application remains in control of recovery.
Multiple dynamic elements
Create one step for each dynamic element before creating the tour:
const dynamicSteps = Array.from(
document.querySelectorAll(".dynamic-card")
).map((card, index) => ({
id: `dynamic-${index + 1}`,
target: card,
title: card.querySelector("h2")?.textContent ?? `Item ${index + 1}`,
content: card.querySelector("p")?.textContent ?? "Dynamic content"
}));
const tour = createTour({
steps: [
{
id: "welcome",
target: "#welcome",
title: "Welcome",
content: "Start the tour here."
},
...dynamicSteps
]
});Create or rebuild the tour after the dynamic elements have been added. A single
selector such as ".dynamic-card" selects only the first matching element.
Tour options
Only steps is required. All other options have sensible defaults.
| Option | Default | What it does |
| --- | --- | --- |
| steps | Required | The ordered list of tour steps. |
| theme | Page theme or "classic" | Sets data-a11y-tour-theme on the generated tour root. Legacy values are normalized. |
| locale | Page language or "en" | Language used for labels and step filtering. |
| fallbackLocale | "en" | Language to use when a translation is missing. |
| translations | {} | Translated button labels and announcements. |
| labels | English labels | Replaces individual labels without adding a full translation. |
| direction | "auto" | Text direction: "ltr", "rtl", or automatic. |
| placement | "auto" | Default dialog position for all steps. |
| modal | true | Prevents interaction with the page behind the tour. |
| scrollToTarget | true | Scrolls each highlighted target into view. |
| smoothScroll | true | Uses smooth scrolling when motion is allowed. |
| reducedMotion | false | Disables tour motion when set to true. |
| keyboardNavigation | true | Allows the arrow keys to move between steps. |
| closeOnEscape | true | Allows Escape to close the tour. |
| showProgress | true | Shows text such as “Step 2 of 4”. |
| checklist | false | Shows a native checkbox for marking each step complete. |
| className | "" | Adds a custom class to the tour root for styling. |
| zIndex | 10000 | Controls whether the tour appears above other page content. |
| on | {} | Event handlers for tour activity. |
Example with a few common options:
const tour = createTour({
theme: "spotlight",
placement: "bottom",
modal: true,
closeOnEscape: true,
showProgress: true,
className: "my-product-tour",
zIndex: 20000,
steps
});Control the tour
createTour() returns a controller with these methods:
| Method | What it does |
| --- | --- |
| start() | Starts at the first step. |
| start(2) | Starts at a step number. Step numbers start at 0. |
| start("settings") | Starts at a step with that id. |
| next() | Moves to the next step. |
| previous() | Moves to the previous step. |
| goTo(2) | Moves to a step number. |
| goTo("settings") | Moves to a step by id. |
| refresh() | Re-resolves and repositions the current target. Returns false when the tour is inactive or destroyed, or its current target is unavailable. |
| skip() | Ends the tour as skipped. |
| complete() | Ends the tour as completed. |
| destroy() | Closes the tour and permanently disposes of this controller. |
| isActive() | Returns true while the tour is open. |
| getCurrentStep() | Returns the current step, or null. |
| isStepCompleted("settings") | Returns whether a step has been marked complete. |
| getCompletedSteps() | Returns completed step IDs in tour order. |
| setStepCompleted("settings", true) | Marks a step complete or incomplete. |
| clearCompletedSteps() | Clears all completed step IDs. |
| on(name, handler) | Adds a typed lifecycle observer and returns an unsubscribe function. |
| off(name, handler) | Removes a lifecycle observer explicitly. |
The navigation methods are asynchronous:
await tour.start();
await tour.goTo("settings");
await tour.next();Do not reuse a controller after calling destroy(). Create a new tour instead.
API
The main runtime API is the createTour(config) function. It returns a
TourController, and the A11yTourGuide class is also exported for advanced
direct construction. TypeScript users can import the TourConfig,
TourStep, TourController, TourLabels, and related event types from the
package root.
Events
Use the on configuration option for observers known when the controller is
created. Shared constants avoid spelling event names as literals:
import { TOUR_EVENTS, createTour } from "a11y-tour-guide";
const tour = createTour({
steps,
on: {
[TOUR_EVENTS.start]: ({ step }) => {
console.log("Tour started at", step.id);
},
[TOUR_EVENTS.stepChange]: ({ step, index, reason }) => {
console.log("Showing step", index, step.id, reason);
},
[TOUR_EVENTS.completionChange]: ({ step, completed, source }) => {
console.log(step.id, completed ? "complete" : "incomplete");
console.log("Changed by", source);
},
[TOUR_EVENTS.complete]: () => {
console.log("Tour completed");
},
[TOUR_EVENTS.skip]: () => {
console.log("Tour skipped");
},
[TOUR_EVENTS.close]: () => {
console.log("Tour closed");
},
[TOUR_EVENTS.error]: ({ error, operation }) => {
console.error(operation, error);
},
[TOUR_EVENTS.destroy]: () => {
console.log("Tour destroyed");
}
}
});Add observers later with the typed controller methods:
const unsubscribe = tour.on(
TOUR_EVENTS.completedStepsChange,
({ completedStepIds, previousCompletedStepIds }) => {
renderProgress(completedStepIds, previousCompletedStepIds);
}
);
// Remove the observer when its owning integration is disposed.
unsubscribe();
// tour.off(TOUR_EVENTS.completedStepsChange, handler) is also available.These are synchronous plain callbacks, not DOM CustomEvents. There is no
event target and no bubbling, composed, or cancelable behavior.
| Event | When it runs | Detail |
| --- | --- | --- |
| start | Once after an inactive tour commits its first visible step | step, index |
| stepchange | After any visible step commits | step, index, previousIndex, reason |
| completionchange | Once per effectively changed step | step state, completed-ID snapshot, source, reason |
| completedstepschange | Once after an effective single or batch completion mutation | previous/current snapshots, changed IDs, source, reason |
| complete | After completed-tour teardown | reason: "completed" |
| skip | After skipped-tour teardown | reason: "skipped" |
| close | After close-button or Escape teardown | reason: "closed" |
| error | After a failed navigation operation closes and cleans up the tour | error, operation, optional step |
| destroy | Once after permanent controller destruction | reason: "destroyed" |
Ordering and async guarantees:
- Initial activation emits
startand thenstepchange. Callingstart()while already active may navigate, but emits onlystepchange. - Terminal and
errorcallbacks observeisActive() === falseand a removed generated root. clearCompletedSteps()retains ordered item-levelcompletionchangecallbacks, then emits one aggregatecompletedstepschange.- Closing or destroying during
beforeShoworcanAdvanceinvalidates that operation. Stale work emits no final lifecycle callbacks. - After
destroy(), no lifecycle callback other than that singledestroyobservation can run. - All observers for one event are attempted. Observer failures propagate to
the caller after state commit, but do not become plugin
errorcallbacks or prevent cleanup.
TypeScript users can import the named detail interfaces, TourEventMap,
TourEventName, and TourEventHandler from the package root.
Synchronize steps with the URL
Import the optional URL adapter from its separate package entry when a tour needs shareable or browser-history-aware steps:
import { createTour } from "a11y-tour-guide";
import { createUrlStepSync } from "a11y-tour-guide/url-sync";
const tour = createTour({ steps });
const urlSync = createUrlStepSync(tour, {
mode: "query",
key: "tour-step",
history: "replace"
});
document.querySelector("#start-tour").addEventListener("click", () => {
void urlSync.start();
});Creating the adapter never starts the tour. urlSync.start() must be called
from an explicit application or user action. It starts at a valid URL step,
falls back to the normal first step when the URL value is unknown, and then
keeps committed stepchange events synchronized. Browser Back and Forward
changes call the controller's normal goTo() path only while the tour is
active, preserving its existing focus and announcement behavior.
The adapter options are:
| Option | Default | Purpose |
| --- | --- | --- |
| mode | "query" | Stores the step in the URL query or parameter-style hash. |
| key | "tour-step" | Names the query or hash parameter. |
| history | "replace" | Uses replaceState; set "push" only when every step should create a Back/Forward entry. |
The returned adapter provides:
| Method | Purpose |
| --- | --- |
| readStep() | Reads the configured URL value without starting or navigating. |
| start(startAt?) | Explicitly starts at the URL step, then falls back to startAt or the first step. |
| sync() | Requests navigation from the current URL while the tour is active. |
| destroy() | Removes tour observers and browser-history listeners. |
Query mode preserves unrelated query parameters and the existing fragment.
Hash mode uses URLSearchParams syntax such as
#section=account&tour-step=settings; it should not be used on pages whose
fragment is owned by traditional anchors or another router. Missing URL values
are ignored, terminal tour states preserve the last step, and destroying the
tour also cleans up the adapter.
Step IDs written to a URL are public navigation data. Query values may appear in server logs, referrer information, copied links, and analytics; hashes may still be collected by client-side analytics. Do not use titles, selectors, form values, account information, or other sensitive data as step IDs. The adapter uses no storage, network requests, analytics, or runtime dependencies.
See examples/url-sync for a complete packaged example.
Let users tick off steps
Set checklist: true to add a native checkbox to each step. Checked steps are
tracked by step id; they do not block Next or Finish.
const tour = createTour({
checklist: true,
steps
});Use optional persistence when a checklist should survive closing and reopening the page:
const tour = createTour({
checklist: {
persist: true,
storageKey: "product-onboarding-checklist",
resumeIncomplete: true
},
steps
});Persisted step IDs are stored in localStorage. If storage is unavailable,
the checklist still works for the current page session. When
resumeIncomplete is enabled, start() opens the first incomplete step, normal
Next and Previous navigation skips checked steps, and checking the current step
advances to the next incomplete step. Progress also reflects the remaining
incomplete path, such as Step 1 of 2 after two steps in a four-step tour are
complete. Explicit calls such as start("settings") still choose that exact step.
Require an action before continuing
Use canAdvance when the user must finish something before selecting Next:
{
id: "accept",
target: "#accept-terms",
title: "Accept the terms",
content: "Select the checkbox before continuing.",
canAdvanceMessage: "Select the checkbox before continuing.",
canAdvance: () => {
return document.querySelector("#accept-terms").checked;
}
}Use beforeShow when a panel or menu needs to open before its step:
{
id: "menu-item",
target: "#menu-item",
title: "Menu item",
content: "This item is inside the menu.",
beforeShow: async () => {
await openMenu();
}
}Change labels
Use labels for small text changes:
const tour = createTour({
labels: {
next: "Continue",
previous: "Back",
finish: "Done"
},
steps
});The available labels are:
nextpreviousskipclosefinishprogressstepAnnouncementcompletedAnnouncementskippedAnnouncementclosedAnnouncementmissingTargetAnnouncementblockedAnnouncementmarkStepComplete
Templates can use {current}, {total}, and {title} where appropriate.
Languages and right-to-left text
Use locale and translations to translate controls and announcements:
const tour = createTour({
locale: "fr",
fallbackLocale: "en",
translations: {
fr: {
next: "Suivant",
previous: "Précédent",
skip: "Ignorer la visite",
close: "Fermer la visite",
finish: "Terminer",
progress: "Étape {current} sur {total}"
}
},
steps
});Set direction: "auto" to choose left-to-right or right-to-left from the
locale. You may also set it directly to "ltr" or "rtl".
Use locales on a step when that step should only appear in some languages:
{
id: "french-help",
target: "#french-help",
title: "Aide",
content: "Aide supplémentaire en français.",
locales: ["fr", "fr-CA"]
}Accessibility behavior
a11y-tour-guide provides the following behavior:
- Focus moves into the labelled dialog for each step.
- Focus returns to the element that started the tour when it closes.
- Tab and Shift+Tab stay inside a modal tour.
- Escape closes the tour when
closeOnEscapeis enabled. - Arrow keys move between steps when keyboard navigation is enabled.
- The dialog exposes its title, content, progress, and status announcements.
- Missing or hidden targets are announced together with the fallback step; verify that the skip reason and the displayed step are both conveyed in a screen reader.
- Blocked steps announce why Next did not move forward.
- Checklist mode uses a native labelled checkbox for each step.
- Skip and close controls remain available throughout the tour.
- Modal tours make the rest of the page inert and stop page scrolling.
- When modal tours overlap, background isolation remains active until the last modal closes; only the topmost modal handles keyboard input, focus stays in a surviving dialog, and focus finally returns to the original launcher when it is still available.
- The target highlight does not block pointer input.
- The dialog stays inside the viewport and repositions when the page changes.
- Motion follows
prefers-reduced-motion.
Present one guided tour at a time in normal product flows. Concurrent tours are handled safely for cleanup and integration edge cases, but competing dialogs can still create confusing instructions for users.
The library cannot make the tour content accessible by itself. Use clear titles, short instructions, stable selectors, and a logical step order. Do not rely only on color or visual position. Test with a keyboard, screen reader, zoom, high contrast mode, translated content, and representative browsers.
Browser support
The package targets modern browsers and ES2022. It uses browser features such
as inert, ResizeObserver, and matchMedia. Add polyfills if you need to
support older browsers.
Project commands
| Command | What it does |
| --- | --- |
| npm run dev | Starts the demo development server. |
| npm run build | Builds the library and refreshes the GitHub Pages site in docs. |
| npm run build:dist | Builds the JavaScript library, CSS, source maps, and TypeScript declarations in dist. |
| npm run build:library | Alias for npm run build:dist. |
| npm run pages:generate | Compiles the demo to the branch-publishable docs folder. |
| npm run pages:build | Builds package artifacts and then regenerates docs. |
| npm run build:demo | Alias for npm run pages:generate. |
| npm run preview | Serves the generated docs site locally. |
| npm run lint | Checks the source with ESLint. |
| npm test | Runs the unit tests. |
| npm run test:e2e | Runs the Chromium accessibility and interaction tests. |
| npm run typecheck | Checks TypeScript without emitting files. |
| npm run pack:check | Runs npm pack --dry-run to inspect package contents. |
| npm run check | Runs lint, unit tests, and the production build. |
GitHub Pages
The static demo is generated into docs/ so GitHub Pages can publish it
directly from the repository branch. Its asset URLs are relative, so the site
works at the repository subpath used by project Pages sites.
Before committing a demo change, regenerate the publishable site:
npm run pages:buildThen configure the repository once in Settings → Pages:
- Under Build and deployment, select Deploy from a branch.
- Select the branch to publish (usually
main). - Select the
/docsfolder and save.
Commit docs/index.html, docs/assets/, and docs/.nojekyll. The package
output in dist/ remains ignored and is not the GitHub Pages source.
Examples
- Guided Proof demo is the homepage: an explicit-start tour with four browser checks for intentional start, focus, keyboard operation, and recovery.
- API reference contains the full public contract without the interactive scenarios around it.
- Interaction laboratory provides isolated advanced behavior and skin checks.
- Basic example imports from the built
distoutput for a release-handoff orientation. - URL synchronization example demonstrates opt-in, explicit-start URL step synchronization.
Docs metadata
The package exports a docs metadata object for documentation aggregators:
import { docs } from "a11y-tour-guide/docs";It includes installation commands, keyboard behavior, generated selectors, public API and event summaries, and links to the repository examples.
License
MIT
