Skip to content

Controlling a tour programmatically

GlowTour.js provides a complete programmatic API for controlling tours, observing state changes, and sequencing complex workflows.

Every adapter’s createGlowTour() function returns a tour controller. It holds state, manages workflows, and dispatches events. In a component, the adapter’s useGlowTour() (Angular: injectGlowTour()) returns the same controller; the examples below use the instance directly so they work in any framework.

import { createGlowTour } from "@glowhop/react-tour";
const tour = createGlowTour();

Access the current tour state and subscribe to changes:

const state = tour.state.get();
console.log(state.status); // "idle" | "starting" | "transitioning" | "active" | "finished" | "cancelled" | "error" | "disposed"
console.log(state.currentStep); // Current step info (or null if not active)
console.log(state.error); // Error if status === "error"

See tour.state.get() for every state field.

const unsubscribe = tour.state.subscribe((newState) => {
console.log("Tour state changed:", newState);
if (newState.status === "finished") {
console.log("Tour finished!");
}
});
// Call unsubscribe() to stop listening
unsubscribe();
const workflow = tour.create("intro").step({ id: "step-1", /* ... */ }).build();
await tour.start(workflow);
console.log("First step is on screen");

start() resolves once the first step is on screen, not when the tour ends. It rejects if that first step fails. To react to the end of the tour, use the workflow’s onFinish and onCancel callbacks, an onEvent listener for tour:complete and tour:cancel, or a subscribe listener that checks status. See The promise trap.

While a tour is active, control it with these methods:

// Move to the next step
await tour.advance();
// Go to the previous step
await tour.previous();
// Jump to a specific step by id
await tour.goTo("billing");
// Hide the popover, keeping the overlay, then show it again
tour.hidePopover();
tour.showPopover();
// Cancel and end the tour
await tour.cancel();
// Clean up and release resources
tour.dispose();

React to tour events at the workflow level:

const workflow = tour
.create("my-tour", {
onStart(context) {
console.log("Tour started on step:", context.step?.initialProps.title);
},
onCancel(context) {
console.log("Tour cancelled by user at step:", context.step?.initialProps.title);
},
onFinish(context) {
console.log("Tour completed all steps, last step:", context.step?.initialProps.title);
},
})
.step({ id: "step1", /* ... */ })
.build();

Run code when a step is entered or left. These are builder methods chained after a .step() call, not options inside it - they attach to the step that precedes them:

const workflow = tour
.create("hooks")
.step({
id: "step1",
target: "#step1",
title: "First",
content: "Step 1",
})
.beforeLeave(async ({ direction }) => {
// Perform async work, e.g., save user progress
if (direction === "advance") await saveProgress();
})
.step({
id: "step2",
target: "#step2",
title: "Second",
content: "Step 2",
})
.beforeEnter(({ direction, props, initialProps }) => {
// Coming back from a later step: start again from the declared props.
if (direction === "previous") props.set(initialProps);
})
.build();
  • .beforeEnter() runs after the step’s target is resolved and before the step is shown, so the props it sets are the first ones rendered.
  • .beforeLeave() runs before advance(), previous(), goTo(), or finishing the tour. It does not run on cancel: use the workflow’s onCancel option, which receives the current step.
  • Both can be async and pause the transition until they resolve. context.direction tells which way the tour is moving.
  • Both can stop the navigation with context.abort(), called synchronously or before their promise resolves. The tour stays on the step it was on and emits no event. When the first step’s beforeEnter aborts, the tour goes back to idle, like an aborted onStart.
.beforeLeave(({ abort, direction }) => {
// Keep the user here until the form is valid.
if (direction === "advance" && !form.checkValidity()) abort();
})

Step props are not reset automatically: a value set with context.props.set() is still there when the tour comes back to the step, until the workflow runs again.

context.props is a small store: get() reads the current props, set() replaces them, and update() merges a partial change into them:

.do(({ props }) => {
// Only this field changes; the advance keys, the other controls, the title and the content are kept.
props.update({ controls: { advance: { state: "enabled" } } });
})
  • Fields left out of the change are kept.
  • data is merged key by key.
  • overlay, popover, indicator, behavior, and controls are merged the way step options merge over the workflow defaults.
  • Arrays such as placementTryOrder or keys are replaced, never concatenated.
  • classNames is merged per component: a component named in the change gets exactly the classes given.

Pass a function to compute the change from the current props:

props.update((current) => ({ data: { clicks: Number(current.data?.clicks ?? 0) + 1 } }));

To add a single class to the current ones, read them in the function form. They are a string or an array:

props.update((current) => ({
classNames: { popover: [current.classNames?.popover ?? [], "popover-highlighted"].flat() },
}));

update() validates and publishes once, like set(). To remove a value, use set().

behavior is part of the step props: context.props.update({ behavior }) changes it while the tour runs, and context.props.get().behavior reads it. Like the other props, the value is kept when the tour comes back to the step, until the workflow runs again.

Each field takes effect when GlowTour reads it:

Field Read A change made during the step
allowInteraction Continuously Applies at once: the page becomes inert or usable again, focus leaves the target when interaction is blocked, and the indicator fades out or back in
allowScroll Continuously Applies at once: page scroll is locked or released
overlayClick On each click on the dimmed area Applies to the next click
autoFocus, autoScroll, scroll When the step is entered, and autoScroll and scroll again each time the user stops scrolling Applies on the next visit, or to this one when set in beforeEnter. autoScroll and scroll also apply to the next return after a user scroll
missingTarget When the target is resolved, and when a lost target is recovered Applies to the next resolution. beforeEnter runs after the target is resolved, so it is too late for the visit in progress

controls works the same way: context.props.update({ controls }) changes a command’s state or its keys during the step. Both are read continuously: the button updates at once, and the keys apply to the next key press.

A button the user may click only once:

.step({
id: "pay",
target: "#pay",
title: "Pay",
content: "Click Pay to continue.",
behavior: { allowInteraction: true },
controls: { advance: { state: "disabled" } },
// Hides the Next button until the click, see "Hiding a control's button" in the builder reference.
classNames: { advance: "tour-hidden" },
})
.onTargetEvent("click", (_event, { props }) => {
props.update({
behavior: { allowInteraction: false },
controls: { advance: { state: "enabled" } },
classNames: { advance: [] },
});
})

To start from the configured behavior on every visit, reset it in beforeEnter:

.beforeEnter(({ props, initialProps }) => props.update({ behavior: initialProps.behavior }))

props.set() replaces every prop, behavior included: spread the current props to keep it, or the step runs without the behavior it was configured with.

Sequence work between steps using .do(), .wait(), and other action methods:

const workflow = tour
.create("with-actions")
.step({
id: "field",
target: "#field",
title: "Enter data",
content: "Type something in this field.",
})
.do(async () => {
console.log("User finished step 1");
})
.wait(1000) // Wait 1 second
.step({
id: "submit",
target: "#submit",
title: "Submit",
content: "Click the submit button.",
})
.waitUntil(() => {
// Wait until form is submitted
return document.querySelector("form")?.dataset.submitted === "true";
})
.step({
id: "success",
target: "#success",
title: "Done!",
content: "Your form was submitted.",
})
.build();

Available actions:

  • .do(fn) - Execute a function (can be async)
  • .wait(ms) - Wait for a duration in milliseconds
  • .waitUntil(fn, options) - Wait until a condition is true (default: checks every 16ms, 3000ms timeout)
  • .waitUntilElement(selector, options) - Wait until an element enters the DOM
  • .clickTarget() - Click the current step’s target element
  • .focusTarget() - Focus the current step’s target element

.append(workflow) splices an already-built workflow’s steps into the one you are building, so you can define reusable fragments once and reuse them across tours:

const profileSteps = tour
.create("profile-fragment")
.step({ id: "profile", target: "#profile", title: "Your profile", content: "Complete it to continue." })
.build();
const workflow = tour
.create("onboarding")
.step({ id: "welcome", target: "#welcome", title: "Welcome", content: "Let's get started!" })
.append(profileSteps)
.step({ id: "dashboard", target: "#dashboard", title: "You're ready!", content: "Explore your dashboard." })
.build();

React to DOM events on the current target:

const workflow = tour
.create("events")
.step({
id: "button",
target: "#button",
title: "Click me",
content: "This button triggers an action.",
})
.onTargetEvent("click", (event, context) => {
console.log("Target was clicked during this step");
})
.step({
id: "next",
target: "#next",
title: "Next",
content: "Continue the tour.",
})
.build();

The event handler receives the native DOM event and the step context.

Pass an array to bind the same handler to several events at once:

.onTargetEvent(["focus", "blur"], (event, context) => {
console.log("Target received:", event.type);
})

A subscriber or step callback that throws is reported to onSubscriberError and does not fail the tour. A fatal error rejects the command and sets status to "error". See Handling errors for how to observe and recover from both.

Here’s a tour that combines multiple features:

// `createGlowTour` only takes controller-level options; lifecycle hooks belong to the workflow.
const tour = createGlowTour({
onSubscriberError(error) {
logger.error("Tour error", error);
},
});
const workflow = tour
.create("onboarding", {
onStart(context) {
analytics.track("tour_started");
},
onCancel({ step }) {
// The lifecycle context carries a snapshot of the step the user was on.
analytics.track("tour_cancelled", { step: step?.id });
},
onFinish(context) {
analytics.track("tour_completed");
},
})
.step({
id: "welcome-2",
target: "#welcome",
title: "Welcome",
content: "Let's get started!",
})
.beforeLeave(async ({ direction }) => {
if (direction === "advance") await api.logEvent("welcome_seen");
})
.wait(500)
.step({
id: "profile-2",
target: "#profile",
title: "Your profile",
content: "Complete your profile to unlock all features.",
})
.waitUntil(() => {
return document.querySelector("form")?.dataset.valid === "true";
})
.do(async () => {
await api.submitProfile();
})
.step({
id: "dashboard-2",
target: "#dashboard",
title: "You're ready!",
content: "Explore your dashboard.",
})
.build();
// Run the tour
await tour.start(workflow);

For the full workflow/step-building API and every option’s default value, see the Builder reference; for the controller API (createGlowTour, tour.start, tour.state, …), see the Tour reference.