A tour guides users through product features using a series of positioned overlays. Each step can target a specific DOM element or appear as a centered modal dialog. The machine manages navigation, spotlight rendering, and step lifecycle.
Features
- Four step types:
tooltip, dialog, floating, and wait
- Spotlight clip-path highlights the target element
- Flexible per-step positioning using the same placement API as tooltips
- Per-step backdrop control
- Step actions (next, prev, dismiss, skip) with custom labels
- Step effects for async data loading and custom logic
- Progress tracking with percentage and text
- Keyboard navigation (arrow keys, Escape)
- Controlled and uncontrolled step management
Installation
npm install @zag-js/tour @zag-js/react
CSS prerequisites
Set box-sizing: border-box globally so the spotlight measurements are accurate:
*, *::before, *::after {
box-sizing: border-box;
}
Set position: relative on body for correct spotlight positioning:
body {
position: relative;
}
import * as tour from "@zag-js/tour"
import { normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"
const steps: tour.StepDetails[] = [
{
id: "welcome",
type: "dialog",
title: "Welcome to the app",
description: "Let us show you around.",
actions: [{ label: "Start tour", action: "next" }],
},
{
id: "feature-a",
type: "tooltip",
placement: "bottom-start",
target: () => document.querySelector("#feature-a"),
title: "Feature A",
description: "This is where Feature A lives.",
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "done",
type: "dialog",
title: "You're all set!",
description: "You've completed the tour.",
actions: [{ label: "Finish", action: "dismiss" }],
},
]
export function TourDemo() {
const service = useMachine(tour.machine, {
id: useId(),
steps,
})
const api = tour.connect(service, normalizeProps)
return (
<>
<button onClick={() => api.start()}>Start Tour</button>
{api.open && (
<>
<div {...api.getBackdropProps()} />
<div {...api.getSpotlightProps()} />
<div {...api.getPositionerProps()}>
<div {...api.getArrowProps()}>
<div {...api.getArrowTipProps()} />
</div>
<div {...api.getContentProps()}>
<p {...api.getProgressTextProps()}>{api.getProgressText()}</p>
<h2 {...api.getTitleProps()}>{api.step?.title}</h2>
<p {...api.getDescriptionProps()}>{api.step?.description}</p>
<div>
{api.step?.actions?.map((stepAction) => (
<button
key={stepAction.label}
{...api.getActionTriggerProps({ action: stepAction })}
>
{stepAction.label}
</button>
))}
</div>
<button {...api.getCloseTriggerProps()}>✕</button>
</div>
</div>
</>
)}
</>
)
}
<script setup>
import * as tour from "@zag-js/tour"
import { normalizeProps, useMachine } from "@zag-js/vue"
import { computed } from "vue"
const steps = [
{
id: "welcome",
type: "dialog",
title: "Welcome",
description: "Let us show you around.",
actions: [{ label: "Start", action: "next" }],
},
]
const service = useMachine(tour.machine, {
id: "tour-1",
steps,
})
const api = computed(() => tour.connect(service, normalizeProps))
</script>
<template>
<button @click="api.start()">Start Tour</button>
<template v-if="api.open">
<div v-bind="api.getBackdropProps()" />
<div v-bind="api.getSpotlightProps()" />
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<h2 v-bind="api.getTitleProps()">{{ api.step?.title }}</h2>
<p v-bind="api.getDescriptionProps()">{{ api.step?.description }}</p>
<button v-bind="api.getCloseTriggerProps()">Close</button>
</div>
</div>
</template>
</template>
Step types
Every step requires an id, title, and description. Use the type field to control how it renders.
| Type | Target required | Description |
|---|
tooltip | Yes | Attaches to a DOM element, positioned relative to it |
dialog | No | Centered modal overlay, useful for intro and outro steps |
floating | No | Floating panel positioned anywhere on the screen |
wait | No | Invisible step — runs an effect and calls next() programmatically |
const steps: tour.StepDetails[] = [
// Tooltip — anchored to an element
{
id: "step-1",
type: "tooltip",
placement: "top-start",
target: () => document.querySelector("#my-element"),
title: "Tooltip Step",
description: "This step is anchored to an element.",
},
// Dialog — centered on screen
{
id: "step-2",
type: "dialog",
title: "Dialog Step",
description: "This step appears as a centered modal.",
},
// Floating — positioned on screen, no target
{
id: "step-3",
type: "floating",
placement: "top-start",
title: "Floating Step",
description: "This step floats on the screen.",
},
// Wait — invisible, used for async transitions
{
id: "step-4",
type: "wait",
title: "Wait Step",
description: "Waiting for user action...",
effect({ next }) {
const button = document.querySelector("#action-button")
const handler = () => next()
button?.addEventListener("click", handler)
return () => button?.removeEventListener("click", handler)
},
},
]
Step placement
For tooltip and floating steps, use the placement property to position the step panel. Accepts all standard placement values plus "center":
{
id: "step-1",
type: "tooltip",
placement: "bottom-end",
target: () => document.querySelector("#target"),
title: "Bottom end",
description: "Positioned at the bottom end of the target.",
}
Supported values: "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end", "center".
Step actions
Define buttons in the step footer with the actions array. Each action has a label and an action key:
| Action value | Behavior |
|---|
"next" | Move to the next step |
"prev" | Move to the previous step |
"dismiss" | End the tour immediately |
"skip" | Skip to the last step or end the tour |
{
id: "middle-step",
type: "tooltip",
target: () => document.querySelector("#nav"),
title: "Navigation",
description: "Use the nav to move between pages.",
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
}
You can also pass a function as the action for custom behavior:
actions: [
{
label: "Go to dashboard",
action: ({ goto }) => goto("dashboard-step"),
},
]
Step effects
A step effect runs before a step is displayed. Use it to fetch data, scroll to an element, or wait for conditions.
The effect function receives:
| Method | Description |
|---|
show() | Display the current step (required to show the step after async work) |
next() | Advance to the next step |
goto(id) | Jump to a specific step by ID |
dismiss() | Dismiss the tour immediately |
update(details) | Update step title or description before showing it |
{
id: "dynamic-step",
type: "tooltip",
target: () => document.querySelector("#metrics"),
title: "Loading...",
description: "",
effect({ show, update }) {
fetchMetrics().then((data) => {
update({ title: data.title, description: data.description })
show()
})
// Optionally return a cleanup function
return () => {}
},
}
Wait steps
A wait step is invisible — it does not render any UI. Use it to pause the tour until a specific user action or condition is met, then call next() to continue.
You cannot call show() inside a wait step effect. Only next(), goto(), and dismiss() are valid.
{
id: "wait-for-click",
type: "wait",
title: "Click the button",
description: "Please click the confirm button to continue.",
effect({ next }) {
const button = document.querySelector("#confirm-btn")
const handler = () => next()
button?.addEventListener("click", handler)
return () => button?.removeEventListener("click", handler)
},
}
Progress tracking
Show the user’s progress through the tour:
// As a percentage (0-100)
const percent = api.getProgressPercent()
// As formatted text (configurable via translations)
const text = api.getProgressText() // e.g. "Step 2 of 5"
// Render with accessible props
<div {...api.getProgressTextProps()}>{api.getProgressText()}</div>
Customize the progress text:
const service = useMachine(tour.machine, {
steps,
translations: {
progressText: ({ current, total }) => `${current} / ${total}`,
},
})
Lifecycle callbacks
const service = useMachine(tour.machine, {
steps,
onStepChange(details) {
// { stepId, stepIndex, totalSteps, complete, progress }
console.log("Current step:", details.stepId)
},
onStepsChange(details) {
// { steps: StepDetails[] }
console.log("Steps updated:", details.steps.length)
},
onStatusChange(details) {
// { status: "started" | "skipped" | "completed" | "dismissed" | "not-found" }
if (details.status === "completed") {
markOnboardingDone()
}
},
})
Programmatic control
api.start() // start at the first step
api.start("step-2") // start at a specific step
api.next() // advance to the next step
api.prev() // go back one step
api.setStep("step-3") // jump to a step by id
api.updateStep("step-2", { title: "Updated title" })
api.addStep({ id: "new-step", type: "dialog", title: "New", description: "..." })
api.removeStep("step-2")
api.setSteps(newSteps)
Dismiss and interaction behavior
const service = useMachine(tour.machine, {
steps,
closeOnEscape: true, // default: true
closeOnInteractOutside: true, // default: true
keyboardNavigation: true, // default: true (arrow keys navigate steps)
preventInteraction: false, // default: false
})
The array of step definitions. Each step requires id, title, and description.
The controlled ID of the currently active step.
onStepChange
(details: StepChangeDetails) => void
Called when the current step changes. Receives stepId, stepIndex, totalSteps, complete, and progress.
onStepsChange
(details: { steps: StepDetails[] }) => void
Called when the steps array is updated.
onStatusChange
(details: StatusChangeDetails) => void
Called when the overall tour status changes. Status values: "started", "skipped", "completed", "dismissed", "not-found".
Close the tour when the user presses Escape. Defaults to true.
Close the tour when the user clicks outside the step content. Defaults to true.
Enable arrow key navigation between steps. Defaults to true.
Block all page interaction while the tour is active. Defaults to false.
Extra space around the spotlight clip path in pixels. Defaults to { x: 10, y: 10 }.
Corner radius of the spotlight clip path. Defaults to 4.
Localized labels for navigation buttons and progress text.
Step details reference
Every entry in the steps array accepts these fields:
Unique identifier for the step.
type
"tooltip" | "dialog" | "floating" | "wait"
How the step is rendered. Defaults to "dialog" when no target is provided.
Function that returns the DOM element to highlight (required for tooltip type).
Positioning of the step panel relative to the target or screen.
Buttons rendered in the step footer. Each has label and action.
Whether to show a backdrop behind this step. Defaults to true.
Whether to show an arrow tip on tooltip steps.
effect
(args: StepEffectArgs) => void | (() => void)
A function run before the step is shown. Optionally returns a cleanup function.
offset
{ mainAxis?: number, crossAxis?: number }
Distance between the step content and the target element.
Styling
Use data-part and data-scope="tour" to target parts in CSS.
Parts reference
| Part | Element | Description |
|---|
backdrop | div | Semi-transparent overlay behind the page |
spotlight | div | Clip-path cutout highlighting the target element |
positioner | div | Positions the step content |
content | div | The step panel containing title, description, and actions |
arrow | div | The tooltip arrow element |
arrowTip | div | The inner tip of the arrow |
title | h2 | The step heading |
description | p | The step body text |
progressText | div | Displays current progress |
actionTrigger | button | A step action button |
closeTrigger | button | Closes or dismisses the tour |
Styling by step type
Apply different styles depending on which step type is active:
/* Dialog type */
[data-scope="tour"][data-part="content"][data-type="dialog"] {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 480px;
}
/* Floating type */
[data-scope="tour"][data-part="content"][data-type="floating"] {
position: fixed;
}
/* Tooltip type */
[data-scope="tour"][data-part="content"][data-type="tooltip"] {
max-width: 360px;
}
Placement-based styling
/* Arrow direction based on placement */
[data-scope="tour"][data-part="positioner"][data-placement*="top"] [data-part="arrow"] {
bottom: -8px;
}
[data-scope="tour"][data-part="positioner"][data-placement*="bottom"] [data-part="arrow"] {
top: -8px;
}
Backdrop and spotlight
[data-scope="tour"][data-part="backdrop"] {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
[data-scope="tour"][data-part="spotlight"] {
position: fixed;
inset: 0;
/* clip-path is applied via inline styles by the machine */
pointer-events: none;
}
Accessibility
The tour follows dialog and tooltip ARIA patterns. Step content uses role="dialog" with aria-labelledby pointing to the title and aria-describedby pointing to the description. Navigation buttons are labeled via translations or default English labels.
Keyboard interactions
| Key | Description |
|---|
ArrowRight / ArrowDown | Move to the next step |
ArrowLeft / ArrowUp | Move to the previous step |
Escape | Dismiss the tour (when closeOnEscape is true) |
Tab | Move focus between interactive elements within the step |