Skip to main content
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;
}

Usage

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>
        </>
      )}
    </>
  )
}

Step types

Every step requires an id, title, and description. Use the type field to control how it renders.
TypeTarget requiredDescription
tooltipYesAttaches to a DOM element, positioned relative to it
dialogNoCentered modal overlay, useful for intro and outro steps
floatingNoFloating panel positioned anywhere on the screen
waitNoInvisible 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 valueBehavior
"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:
MethodDescription
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
})

Props

steps
StepDetails[]
The array of step definitions. Each step requires id, title, and description.
stepId
string | null
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".
closeOnEscape
boolean
Close the tour when the user presses Escape. Defaults to true.
closeOnInteractOutside
boolean
Close the tour when the user clicks outside the step content. Defaults to true.
keyboardNavigation
boolean
Enable arrow key navigation between steps. Defaults to true.
preventInteraction
boolean
Block all page interaction while the tour is active. Defaults to false.
spotlightOffset
{ x: number, y: number }
Extra space around the spotlight clip path in pixels. Defaults to { x: 10, y: 10 }.
spotlightRadius
number
Corner radius of the spotlight clip path. Defaults to 4.
translations
IntlTranslations
Localized labels for navigation buttons and progress text.

Step details reference

Every entry in the steps array accepts these fields:
id
string
required
Unique identifier for the step.
type
"tooltip" | "dialog" | "floating" | "wait"
How the step is rendered. Defaults to "dialog" when no target is provided.
title
any
required
The step heading.
description
any
required
The step body text.
target
() => HTMLElement | null
Function that returns the DOM element to highlight (required for tooltip type).
placement
StepPlacement
Positioning of the step panel relative to the target or screen.
actions
StepAction[]
Buttons rendered in the step footer. Each has label and action.
backdrop
boolean
Whether to show a backdrop behind this step. Defaults to true.
arrow
boolean
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

PartElementDescription
backdropdivSemi-transparent overlay behind the page
spotlightdivClip-path cutout highlighting the target element
positionerdivPositions the step content
contentdivThe step panel containing title, description, and actions
arrowdivThe tooltip arrow element
arrowTipdivThe inner tip of the arrow
titleh2The step heading
descriptionpThe step body text
progressTextdivDisplays current progress
actionTriggerbuttonA step action button
closeTriggerbuttonCloses 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

KeyDescription
ArrowRight / ArrowDownMove to the next step
ArrowLeft / ArrowUpMove to the previous step
EscapeDismiss the tour (when closeOnEscape is true)
TabMove focus between interactive elements within the step

Build docs developers (and LLMs) love