# Guidepost > Accessible product tours built on native browser primitives: the Popover API, CSS anchor positioning, `inert`, and an animated `clip-path` spotlight. 9.7 kB minified and gzipped with the stylesheet included, zero runtime dependencies, MIT licensed. - npm package: `@cmunns/guidepost` - Live demo (registers WebMCP tools an agent can call): https://guidepost.live - Source and issues: https://github.com/cmunns/guidepost - Every docs page in one file: https://docs.guidepost.live/llms-full.txt --- # Introduction Source: https://docs.guidepost.live/ Accessible product tours built on browser primitives. No positioning library, no portals, no z-index fights. Accessible product tours built on native browser primitives. No positioning library. No portals. No fighting with z-index, and no hand-rolled focus trap. **MIT licensed.** 9.7 kB minified + gzipped, stylesheet included. Zero runtime dependencies. ```bash npm install @cmunns/guidepost ``` ```js import { Tour } from '@cmunns/guidepost'; const tour = new Tour({ id: 'onboarding', steps: [ { title: 'Welcome', text: 'A quick tour.', placement: 'center' }, { target: '#sidebar', title: 'Navigation', text: 'Everything lives here.', placement: 'right-start' }, { target: '#search', title: 'Try it', text: 'Type to continue.', focus: 'target', advanceOn: { event: 'input', when: (e) => e.target.value.length > 2 } }, ], }); tour.start(); ``` Every example in these docs runs live at [the live demo](https://guidepost.live). ## What it's built on **Popover API.** The card lives in the top layer, so you're never portaling it somewhere else, escalating z-index, or trying to escape `overflow: hidden`. **CSS anchor positioning.** The browser keeps the card stuck to its target. Browsers that don't have it yet get a measured JS fallback. **clip-path spotlight.** One element, and you get a click-through hole for free, so the user can actually do the thing the step is talking about. **inert.** The platform handles the focus trap. Everything else is untabbable, unclickable, and invisible to screen readers. No keydown interception, no sentinel nodes. ## Where to go next **Install.** [Install](/install) the package and decide who owns the CSS pipeline. **Quick start.** [Quick start](/quick-start). A tour is an array of steps and a call to `start()`. **Blocking modes.** [Blocking modes](/guides/blocking-modes). Three combinations, and this choice matters way more than any styling decision. **Reference.** [Steps](/reference/steps), [Options](/reference/options) and [Methods](/reference/methods). The full surface. --- # Install Source: https://docs.guidepost.live/install/ Grab it from npm. ESM and CJS, TypeScript types included, and the CSS shows up on its own. ```bash npm install @cmunns/guidepost ``` Ships ESM and CJS with TypeScript types. The stylesheet gets injected the first time you use it. Want to own your CSS pipeline? Import it yourself and pass `injectStyles: false`: ```js // only if you set injectStyles: false import '@cmunns/guidepost/guidepost.css'; ``` --- # Quick start Source: https://docs.guidepost.live/quick-start/ An array of steps, a call to start(), and you're done. A tour is an array of steps and a call to `start()`. ```js import { Tour } from '@cmunns/guidepost'; const tour = new Tour({ steps: [ { title: 'Welcome', text: 'A quick tour.', placement: 'center' }, { target: '#sidebar', title: 'Navigation', text: 'Everything lives here.' }, { target: '#new-button', title: 'Create', text: 'Start here when you are ready.' }, ], }); tour.start(); ``` That's the whole thing. Focus jumps into the card, the rest of the page goes inert, Escape gets you out, arrow keys move around, and the highlighted element stays clickable. You can play with this tour, and every other example in these docs, over at [the live demo](https://guidepost.live). --- # Placement Source: https://docs.guidepost.live/guides/placement/ Four sides with optional alignment, plus center for a card with no target, and what happens when the card doesn't fit. Four sides, each with an optional alignment: `top`, `right`, `bottom`, `left`, any of them suffixed `-start`, `-center` or `-end`, plus `center` for a card with no target at all. You're stating a preference here, not a coordinate. When the card doesn't fit, the browser's `position-try-fallbacks` flips it. On the JS path, your `fallbackPlacements` get tried in order and the card gets clamped into the viewport. The arrow is measured afterward, so it always agrees with where the card actually landed. Each placement is demonstrated live at [the live demo](https://guidepost.live). --- # Blocking modes Source: https://docs.guidepost.live/guides/blocking-modes/ Three combinations of blocking and interactive, and why this choice matters more than any styling decision. Three combinations, and this choice matters more than any styling decision you'll make. ## Blocking and interactive (the default) The page is dimmed and inert, but the spotlighted element stays live, so the user can do the thing the step is describing. The cutout passes clicks through because `clip-path` affects hit testing. ```js new Tour({ steps }); // blocking: true, interactive: true ``` ## Fully blocking Nothing outside the card responds. Good for steps that are purely explanatory, where a stray click would derail everything. This is the only mode that sets `aria-modal`, because it's the only one where that's actually true. ```js new Tour({ steps, interactive: false }); ``` ## Non-blocking Hints alongside a working app. Nothing goes inert, nothing intercepts clicks, and the user can ignore the tour completely. You get the weakest focus guarantees here, and that trade is on purpose. ```js new Tour({ steps, blocking: false }); ``` All three modes are running side by side at [the live demo](https://guidepost.live). --- # Async steps Source: https://docs.guidepost.live/guides/async-steps/ Waiting for a target, advancing on the real user action, and doing setup before a step runs. ## Waiting for a target Menus that open, panels that lazy-render, routes that are still loading. `waitFor` takes milliseconds to wait for the target to show up, or a predicate to poll. ```js { target: '#invite-panel', waitFor: 3000, title: 'Invite your team' } { target: '#chart', waitFor: () => store.getState().loaded, title: 'Your data' } ``` The card stays hidden while it waits, so nothing hovers over a half-rendered page. If the target never shows up, `onMissingTarget` decides what happens: skip the step, show it centred, or throw. ## Advancing on the real action Probably the most useful thing in the library. Skip the Next button and let the step complete when the user does the thing. ```js { target: '#search', title: 'Try a search', focus: 'target', advanceOn: { event: 'input', when: (e) => e.target.value.length > 2 }, } ``` There's a working `advanceOn` example over at [the live demo](https://guidepost.live). Type three characters and the step completes. ## Setting up before a step ```js { target: '#settings-panel', beforeShow: async () => { router.push('/settings'); await nextPaint(); }, afterHide: () => router.back(), } ``` --- # Buttons and labels Source: https://docs.guidepost.live/guides/buttons-and-labels/ Swap out the default button row per step, and override every string the card renders. By default you get Back plus Next, and Next turns into Done on the last step. Swap that out per step: ```js { target: '#billing', buttons: [ { label: 'Not now', action: 'cancel', variant: 'ghost' }, { label: 'Skip ahead', action: (tour) => tour.goTo('summary') }, { label: 'Upgrade', action: (tour) => { openBilling(); tour.complete(); }, variant: 'primary' }, ], } ``` `action` is `'next'`, `'back'`, `'cancel'`, `'complete'`, or your own function. `variant` is `primary`, `secondary` or `ghost`. A disabled button gets `aria-disabled` and not the real `disabled` attribute, so it stays reachable and screen readers can still read it. ## Every string is yours ```js new Tour({ steps, labels: { next: 'Weiter', back: 'Zurück', done: 'Fertig', close: 'Tour schließen', counter: (i, total) => `Schritt ${i + 1} von ${total}`, }, }); ``` There's a German-language tour in the live examples at [the live demo](https://guidepost.live). --- # Styling Source: https://docs.guidepost.live/guides/styling/ Everything's a custom property. Light and dark both work out of the box, so override whichever you want. Everything's a custom property. Light and dark are both defined out of the box, so override one or both. ```css .gp-card { --gp-accent: #0f766e; --gp-surface: #fff; --gp-text: #12171c; --gp-radius: 10px; --gp-card-width: 24rem; --gp-font: 400 15px/1.55 "Inter", system-ui, sans-serif; } .gp-scrim { --gp-overlay: rgb(0 0 0 / .55); } .gp-ring { --gp-ring-halo: rgb(15 118 110 / .3); } ``` Class hooks, all of them also exposed as `part` attributes: `.gp-scrim`, `.gp-ring`, `.gp-card`, `.gp-arrow`, `.gp-counter`, `.gp-title`, `.gp-body`, `.gp-progress`, `.gp-dot`, `.gp-actions`, `.gp-btn`, `.gp-close`. Got a one-off step? `classes` lands on the card, so `.gp-card.danger { --gp-accent: #b91c1c; }` does what you'd expect. There's a restyled tour running at [the live demo](https://guidepost.live). --- # Accessibility Source: https://docs.guidepost.live/guides/accessibility/ What the library guarantees around focus, inert, announcements and reduced motion, plus the parts that are still on you. What the library guarantees: - Focus moves into the card on open and goes back to the triggering element when you exit. - Everything except the card is `inert`, meaning untabbable, unclickable, and hidden from screen readers. In interactive mode the spotlighted target is exempt too. No keydown interception, no sentinel nodes. - The step counter is part of the card's accessible name, so it reads as *"Step 2 of 5, Invite your team"* in one go and doesn't arrive as a separate interruption. If focus is already inside the card, step changes route through a polite live region so nothing gets announced twice. - Escape always exits, from anywhere, even while focus is on the target. - `aria-modal` is set only in fully blocking mode. - Every animation collapses under `prefers-reduced-motion`. :::caution[The parts that are still on you.] Write step text that makes sense read aloud without the visual spotlight. "The button below" means nothing to a screen-reader user. Give every target an accessible name. And if a step teaches a drag interaction, give people a keyboard path to the same outcome. No library can make your integration conformant. All it can do is stop being the reason it isn't. ::: --- # Recipes Source: https://docs.guidepost.live/guides/recipes/ Show a tour once per user, deep-link a step, clean up on unmount, and skip steps conditionally. ## Show it once per user ```js if (!Tour.hasCompleted('onboarding')) { new Tour({ id: 'onboarding', remember: true, steps }).start(); } // a "replay tour" menu item Tour.clearCompleted('onboarding'); ``` ## Deep-link a step ```js const step = new URL(location.href).searchParams.get('tour'); if (step) tour.start(step); // start(at) takes a step id ``` ## Clean up on unmount ```js // React useEffect(() => { const tour = new Tour({ steps }); tour.start(); return () => tour.destroy(); }, []); ``` `destroy()` ends the tour, releases `inert`, restores the anchor name on the target and rips out the DOM. Always call it. A tour that outlives its page will leave the page inert, and that's a bad afternoon. ## Conditional steps ```js { target: '#admin-panel', when: () => user.isAdmin, title: 'Admin tools' } ``` Skipped steps don't count towards the total, so a five-step tour that skips two reads "Step 2 of 3" and you don't get weird gaps in the numbering. --- # Agents and WebMCP Source: https://docs.guidepost.live/guides/agents/ Let an AI agent drive a tour through the browser's own tool API instead of clicking around the DOM, and what this site does to be readable by LLMs. Guidepost is built on browser primitives, and browsers are growing one more: [WebMCP](https://developer.chrome.com/docs/ai/webmcp), a way for a page to hand an agent a list of typed tools instead of making it guess at buttons. A tour is a natural fit. It already has a small, well-defined controller (`start`, `next`, `back`, `goTo`, `cancel`), and those map onto tools one for one. [The live demo](https://guidepost.live) does exactly this. Open it in a browser with WebMCP enabled and an agent can start the tour, pick a mode, step through it, and read where it is, without touching a pixel. ## What the demo registers | Tool | What it does | | --- | --- | | `start_tour` | Starts the tour. Optional `mode` (`default`, `js`, `open`) and `step` (a step id) | | `next_step`, `previous_step` | Move one step | | `go_to_step` | Jump to a step by id | | `end_tour` | Dismiss the tour | | `get_tour_state` | Read-only. Whether a tour is running, the current step, the mode, and every step's id and title | | `get_browser_support` | Read-only. Which underlying browser features this browser has | | `get_library_info` | Read-only. Package name, version, install command, docs links | Every mutating tool returns the new tour state as JSON, so an agent never has to follow up with a read to learn what happened. ## Wrap your own tour Registration is a few lines. Feature-detect, describe each tool with a JSON Schema, and call the controller from `execute`. ```js import { Tour } from '@cmunns/guidepost'; const tour = new Tour({ id: 'onboarding', steps }); const ids = steps.map((s) => s.id); const state = () => JSON.stringify({ active: tour.isActive, step: tour.current?.id ?? null, index: tour.index, total: steps.length, }); const tools = [ { name: 'start_tour', description: 'Start the onboarding tour. Optionally start at a specific step id.', inputSchema: { type: 'object', properties: { step: { type: 'string', enum: ids, description: 'Step id to start at.' } }, }, execute: async ({ step }) => { await tour.start(step ?? 0); return state(); }, }, { name: 'next_step', description: 'Advance the running tour by one step. Finishes the tour on the last step.', inputSchema: { type: 'object', properties: {} }, execute: async () => { await tour.next(); return state(); }, }, { name: 'get_tour_state', description: 'Read whether the tour is running and which step it is on.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true }, execute: async () => state(), }, ]; // document.modelContext is where Chrome puts it. Older drafts used navigator. const modelContext = document.modelContext ?? navigator.modelContext; if (modelContext?.registerTool) { for (const tool of tools) modelContext.registerTool(tool); } ``` Three habits that make tools worth calling: - **Enumerate what you can.** Step ids as an `enum` mean the agent can't ask for a step that doesn't exist, and the schema doubles as documentation. - **Return state, not "ok".** `await tour.next()` resolves after the step is on screen, so the state you return is the state the user is looking at. - **Mark reads read-only.** `annotations.readOnlyHint` lets an agent call them freely while it decides what to do. Chrome's [tool-building guide](https://developer.chrome.com/docs/ai/webmcp/build-tools) and [security guidance](https://developer.chrome.com/docs/ai/webmcp/secure-tools) cover naming, descriptions and what not to expose. ## Turning it on WebMCP ships in Chrome 149+ behind an [origin trial](https://developer.chrome.com/origintrials). Register your origin, then put the token on the page: ```html ``` For local development, enable `chrome://flags/#enable-webmcp-testing` instead. The API is absent everywhere else, and the snippet above does nothing there, so it is safe to ship unconditionally. To see what a page has registered, run Lighthouse's [Registered WebMCP tools](https://developer.chrome.com/docs/lighthouse/agentic-browsing/registered-webmcp-tools) audit, or in the console: ```js (await document.modelContext.getTools()).map((t) => t.name); ``` ## Readable by LLMs Both sites also publish plain-text summaries for models that read pages rather than run them: - [guidepost.live/llms.txt](https://guidepost.live/llms.txt), what the library is, the API in brief, and the tools the demo registers - [docs.guidepost.live/llms.txt](https://docs.guidepost.live/llms.txt), an index of these docs - [docs.guidepost.live/llms-full.txt](https://docs.guidepost.live/llms-full.txt), every page in one file Both `robots.txt` files allow AI crawlers by name, and both sites have sitemaps. --- # Troubleshooting Source: https://docs.guidepost.live/guides/troubleshooting/ Things that go wrong when a tour misbehaves, and what's actually causing each one. | Symptom | Cause | | --- | --- | | Card in the corner | The target got removed while the step was open. Give the step a `waitFor`, or use `onMissingTarget: 'center'`. | | Page stays inert | You never called `destroy()`. Usually a component that unmounted mid-tour. | | Target is not clickable | `interactive: false` is set on the tour or the step. | | Card under a modal | The other modal is in the top layer too and opened later. Start the tour after it, or use a `beforeShow` that closes it. | | No animation | The OS is set to reduce motion, which is working as intended. `animate: true` overrides it, but think hard before you do. | | Arrow points at nothing | The target moved after the step opened. Call `tour.refresh()`, or check for a CSS transition on the target. | --- # Steps Source: https://docs.guidepost.live/reference/steps/ Every field on a TourStep. Only target and some content really matter on their own, everything else has a sane default. Only `target` and some content really matter on their own. Everything else has a working default. | Field | Type | What it does | | --- | --- | --- | | `id` | `string` | Stable handle for `goTo('id')`, deep links and analytics. | | `target` | `string \| Element \| fn` | CSS selector, element, or a function that returns one. Leave it off (or pass `null`) for a centred step. | | `title` | `string` | Heading. Becomes part of the card's accessible name. | | `text` | `string` | Body copy, inserted as text. Never parsed as HTML. | | `html` | `string \| HTMLElement` | Escape hatch for markup. Sanitising is on you. | | `placement` | `Placement` | See [Placement](/guides/placement). Falls back to the tour's. | | `fallbackPlacements` | `Placement[]` | Placements to try in order when your first pick doesn't fit (JS strategy). | | `offset` | `number` | Gap between target and card, in px. Default 12. | | `padding` | `number` | Breathing room around the spotlight cutout. Default 6. | | `radius` | `number` | Spotlight corner radius. Defaults to whatever the target already has. | | `interactive` | `boolean` | Keep this target clickable. Overrides the tour setting. | | `buttons` | `StepButton[]` | Replaces the default row. See [Buttons](/guides/buttons-and-labels). | | `scrollTo` | `boolean` | Default true, but it only scrolls if the target is actually clipped. | | `waitFor` | `number \| fn` | Milliseconds to wait for the target, or a predicate to await. Default 0, so no waiting. | | `advanceOn` | `{ selector?, event, when? }` | Move on when the user performs the real action. | | `beforeShow` | `(tour) => void \| Promise` | Runs before the step renders. Await it to stall. | | `afterHide` | `(tour) => void \| Promise` | Runs after the step is torn down. | | `focus` | `'card' \| 'target' \| 'none'` | Where focus lands. Default `card`. | | `when` | `() => boolean` | Return false and the step gets skipped entirely. | | `classes` | `string` | Extra class names on the card, for per-step styling. | ## StepButton | Field | Type | What it does | | --- | --- | --- | | `label` | `string` | Visible label. | | `action` | `'next' \| 'back' \| 'cancel' \| 'complete' \| ((tour) => void)` | Built-in action, or your own handler. | | `classes` | `string` | Extra class names for styling hooks. | | `variant` | `'primary' \| 'secondary' \| 'ghost'` | Visual weight. Defaults to `secondary` for back, `primary` for next. | | `disabled` | `boolean` | Gets `aria-disabled` and not the real `disabled` attribute, so it stays focusable. | ## AdvanceOn | Field | Type | What it does | | --- | --- | --- | | `selector` | `string` | Element to listen on. Defaults to the step's target. | | `event` | `string` | DOM event name, e.g. `click`, `input`, `submit`. | | `when` | `(event) => boolean` | Only advance when this returns true. | --- # Options Source: https://docs.guidepost.live/reference/options/ Every option new Tour(options) takes, what it defaults to, and the lifecycle callbacks. | Option | Default | What it does | | --- | --- | --- | | `steps` | none | Required. | | `id` | none | Used by `remember`, and written into the root as a data attribute. | | `blocking` | `true` | Apply `inert` to the rest of the page. Also hides that content from assistive tech. | | `interactive` | `true` | Keep the spotlighted element usable. Ignored when `blocking` is false. | | `placement` | `'bottom'` | Default for every step. | | `fallbackPlacements` | `[]` | Tried in order when your first pick doesn't fit (JS strategy). | | `offset` / `padding` / `radius` | `12` / `6` / auto | Tour-wide geometry defaults. | | `strategy` | `'auto'` | `auto`, `anchor` or `js`. See [Browser support](/reference/browser-support). | | `keyboard` | `true` | Escape, arrows, Home and End. | | `exitOnEscape` | `true` | Escape cancels the tour. | | `exitOnOverlayClick` | `false` | Clicking the dim cancels. Needs `blocking`. | | `showClose` | `true` | Show the × in the card corner. | | `showProgress` | `true` | Show the progress dots. | | `showCounter` | `true` | Show the "Step n of m" line. | | `animate` | `'auto'` | `auto` honours `prefers-reduced-motion`, `true` always animates, `false` never does. | | `flip` | `true` | Morph the card between steps instead of jumping. The shell glides and resizes while the content cross-fades. | | `injectStyles` | `true` | Set false if you're importing the CSS yourself. | | `container` | `document.body` | Where the tour DOM gets appended. | | `onMissingTarget` | `'skip'` | `skip`, `center` or `error`. | | `remember` | `false` | Writes `guidepost:` to localStorage on completion. | | `labels` | English | Every string is overridable. See [Labels](/guides/buttons-and-labels). | ## Labels `labels` takes a `Partial`, so override only the strings you care about. | Field | Type | What it does | | --- | --- | --- | | `next` | `string` | Next button. | | `back` | `string` | Back button. | | `done` | `string` | Next button on the last step. | | `close` | `string` | Accessible name for the close ×. | | `counter` | `(index, total) => string` | Builds the card's counter text, e.g. "Step 2 of 5". | | `progress` | `string` | Accessible name for the progress dots. | ## Callbacks ```js new Tour({ steps, onStart: (tour) => analytics.track('tour_started'), onShow: (tour, step, i) => analytics.track('tour_step', { id: step.id, i }), onComplete: (tour) => analytics.track('tour_completed'), onCancel: (tour) => analytics.track('tour_abandoned', { at: tour.index }), onDestroy: (tour) => {}, }); ``` --- # Methods Source: https://docs.guidepost.live/reference/methods/ The Tour instance methods, the getters, and the two static helpers behind remember. | Method | Returns | | | --- | --- | --- | | `start(at?)` | `Promise` | Opens the tour. `at` is a step index or id. | | `next()` / `back()` | `Promise` | Move one step, skipping any whose `when()` is false. | | `goTo(indexOrId)` | `Promise` | Jump to a step. | | `complete()` / `cancel()` | `Promise` | End the tour. Only `complete()` counts for `remember`. | | `refresh()` | `void` | Recompute position and spotlight without changing step. | | `destroy()` | `void` | Ends the tour and rips out its DOM. Call this on unmount. | | `index` / `current` / `isActive` | getters | Current state. | | `Tour.hasCompleted(id)` | `boolean` | Static. Reads the `remember` flag. | | `Tour.clearCompleted(id)` | `void` | Static. Lets a user replay the tour. | --- # Browser support Source: https://docs.guidepost.live/reference/browser-support/ What's required with no fallback, and the one piece (CSS anchor positioning) that has one. Required, no fallback: the Popover API, `inert`, and `clip-path: path()`. That puts the floor at roughly Chrome 117, Safari 17.5 and Firefox 129. The spotlight is written as `clip-path: shape()` where the browser has it (Chrome 137+, Safari 18.4+, Firefox 141+) and as `path()` elsewhere. The geometry is identical, but the `shape()` form sidesteps a Chromium bug: its compositor mis-sizes the mask while a `path()` transition runs, so the dim covers only part of the viewport until the spotlight lands. On Chromium builds older than 137 that glitch is still visible during step changes. CSS anchor positioning is the one piece that isn't everywhere yet (Chrome 125+, Safari 26+, Firefox 147+), and the one piece with a fallback. `strategy: 'auto'` uses the native tether where it exists and a measured positioner everywhere else. It also verifies the anchor actually resolved, and falls back mid-step if it didn't. ```js import { supportsAnchorPositioning } from '@cmunns/guidepost'; supportsAnchorPositioning(); // true on the native path ``` [the live demo](https://guidepost.live) tells you which path your browser is on.