Skip to content

How to build a product tour in Solid

This guide shows how to build a product tour or onboarding tour in Solid with @glowhop/solid-tour, the GlowTour.js Solid adapter. It provides components and a Context-scoped tour instance using Solid’s reactivity model. Content is normal Solid JSX.

Install the adapter and the default theme. The theme is imported once, as shown in the example below.

Terminal window
npm i @glowhop/solid-tour @glowhop/styles-tour

useGlowTour() creates a tour for the component and returns everything needed to drive it: the tour to render, its methods (create, start, advance, previous, goTo, cancel), and one accessor per state field.

import { Show } from "solid-js";
import { render } from "solid-js/web";
import "@glowhop/styles-tour/default.css";
import { GlowTourDefault, useGlowTour } from "@glowhop/solid-tour";
function TourApp() {
const { tour, create, start, cancel, status, currentStepIndex, totalSteps } = useGlowTour();
const workflow = create("product-tour")
.step({
id: "features",
target: '[data-tour="features"]',
title: "Explore features",
content: "Learn about all the capabilities.",
})
.step({
id: "pricing",
target: '[data-tour="pricing"]',
title: "Check pricing",
content: "See plans that fit your needs.",
})
.build();
return (
<>
<main>
<section data-tour="features">
<h2>Features</h2>
<p>We offer guided tours, SSR support, and full keyboard navigation.</p>
</section>
<section data-tour="pricing">
<h2>Pricing</h2>
<p>Open source and free.</p>
</section>
<Show
when={status() === "active"}
fallback={<button onClick={() => void start(workflow)}>Start tour</button>}
>
<p>
Step {currentStepIndex() + 1} of {totalSteps()} <button onClick={() => void cancel()}>Stop</button>
</p>
</Show>
</main>
<GlowTourDefault tour={tour} />
</>
);
}
render(() => <TourApp />, document.getElementById("app")!);

The tour is disposed when the component’s owner is cleaned up.

To drive the same tour from several components, see Share one tour between components.

A step’s target is the element the tour highlights. It accepts three forms:

Form Example Use it for
CSS selector '[data-tour="pricing"]', "#pricing" Markup you render yourself
Function () => element A ref, or an element that appears later
HTMLElement document.body An element that already exists when the workflow is built

Selectors and functions are resolved each time the step is entered, not when the workflow is built.

Ids break as soon as a component renders twice, and classes change with styling. A dedicated attribute states the intent and survives both:

<section data-tour="pricing">
<h2>Pricing</h2>
</section>
.step({ id: "pricing", target: '[data-tour="pricing"]', title: "Pricing", content: "Pick a plan." })

A selector matches the first element in the document, wherever it is rendered, so it keeps working with portals. When a component renders several times, target the one you mean through a ref.

Wrap the ref in a function. The function runs when the step is entered, after the component has mounted, so it reads the rendered element:

import { GlowTourDefault, useGlowTour } from "@glowhop/solid-tour";
export function Checkout() {
let payButton: HTMLButtonElement | undefined;
const { tour, create, start } = useGlowTour();
const workflow = create("checkout")
.step({
id: "pay",
target: () => payButton ?? null,
title: "Pay",
content: "Confirm your order here.",
})
.build();
return (
<>
<button ref={payButton}>Pay</button>
<button onClick={() => void start(workflow)}>Show me</button>
<GlowTourDefault tour={tour} />
</>
);
}

Do not read the ref while building the workflow (target: payButton): the element is not rendered yet, so the step would get nothing.

The function can return a promise, for content that loads or opens after the tour has started. It receives a signal that aborts when the tour is cancelled or disposed, so a pending wait can stop:

.step({
id: "results",
target: async ({ signal }) => {
await loadResults({ signal }); // your own async work
return document.querySelector<HTMLElement>('[data-tour="results"]');
},
title: "Results",
content: "Your matches appear here.",
})

A selector that matches nothing, a function that returns null, or an element that is no longer in the page makes the step follow behavior.missingTarget. By default the tour fails with an error. Use "wait" to resolve the target again every 16 ms until a timeout (a function target is called each time, so keep it cheap), "skip" to move past the step, or "detached" to show the popover centered on the screen. See Handling errors.

The target must be an HTML element of the page: an SVG element makes the tour fail with a TypeError. To highlight an SVG graphic, target its HTML container.

GlowTourDefault is the shortest path to a complete tour. Keep it while you only need visual changes, then move to composition when you need to change the popover structure.

The default component reads the theme’s CSS custom properties, so colors, spacing, and shape can change without replacing any components:

:where([data-glow-tour-root]) {
--glow-tour-color-accent: #7c3aed;
--glow-tour-color-surface: #faf5ff;
--glow-tour-radius: 16px;
}

Keep rendering <GlowTourDefault tour={tour} />. See the theming guide for all available tokens.

When you need to add, remove, or rearrange content, expand GlowTourDefault into the primitives it assembles for you:

import {
GlowTourAdvanceTrigger,
GlowTourCancelTrigger,
GlowTourContent,
GlowTourFooter,
GlowTourHeader,
GlowTourOverlay,
GlowTourPointer,
GlowTourPopover,
GlowTourPreviousTrigger,
GlowTourRoot,
} from "@glowhop/solid-tour";
export function CustomTour() {
return (
<GlowTourRoot tour={tour}>
<GlowTourOverlay />
<GlowTourPointer />
<GlowTourPopover>
<GlowTourHeader />
<GlowTourContent />
<GlowTourFooter>
<GlowTourCancelTrigger />
<GlowTourPreviousTrigger />
<GlowTourAdvanceTrigger />
</GlowTourFooter>
</GlowTourPopover>
</GlowTourRoot>
);
}

The same layout can use the GlowTour object, which groups the composition components without their prefix:

import { GlowTour } from "@glowhop/solid-tour";
export function CustomTour() {
return (
<GlowTour.Root tour={tour}>
<GlowTour.Overlay />
<GlowTour.Pointer />
<GlowTour.Popover>
<GlowTour.Header />
<GlowTour.Content />
<GlowTour.Footer>
<GlowTour.CancelTrigger />
<GlowTour.PreviousTrigger />
<GlowTour.AdvanceTrigger />
</GlowTour.Footer>
</GlowTour.Popover>
</GlowTour.Root>
);
}

The object brings every composition component into your bundle. Import components by name to keep only the ones you use.

useGlowTour() gives state to the component that starts the tour. Components rendered inside GlowTourRoot read the same state with useGlowTourContext(), without receiving the tour. Add this small component to the popover from the previous example:

import { Show } from "solid-js";
import { useGlowTourContext } from "@glowhop/solid-tour";
function StepCounter() {
const state = useGlowTourContext();
return (
<Show when={state().currentStepIndex >= 0 && state().totalSteps > 0}>
<p>
Step {state().currentStepIndex + 1} of {state().totalSteps}
</p>
</Show>
);
}
<GlowTourPopover>
<GlowTourHeader />
<StepCounter />
<GlowTourContent />
{/* Keep the same footer as above. */}
</GlowTourPopover>

To have assistive technologies announce the complete counter when it changes, you can add aria-live="polite" and aria-atomic="true" to the <p>. GlowTourContent is already a polite live region, so enable a second one only when the counter conveys useful distinct information, and test the result with a screen reader.

See the runnable Live step counter example.

When several components drive the same tour, for example a layout that renders it and pages that start it, create the tour once with createGlowTour() and pass it to useGlowTour:

tour.ts
import { createGlowTour } from "@glowhop/solid-tour";
export const tour = createGlowTour();
import { GlowTourDefault, useGlowTour } from "@glowhop/solid-tour";
import { tour } from "./tour";
export function App() {
return (
<>
<HelpButton />
<GlowTourDefault tour={tour} />
</>
);
}
function HelpButton() {
const { status } = useGlowTour(tour);
return <button disabled={status() === "active"}>Help</button>;
}

useGlowTour(tour) reads a tour it is given and never disposes it. Outside components, drive the same instance directly with tour.start(workflow), tour.cancel(), and tour.state.

GlowTour.js requires Solid 1.8 or later. The adapter uses Solid’s Context API and signals for state management.

GlowTourDefault supports server-side rendering. The component renders as an inert container on the server and hydrates correctly on the client. With SolidStart, see With SolidStart in the SSR guide.