Skip to content

Theming a product tour with CSS

GlowTour.js provides a complete default theme via @glowhop/styles-tour/default.css, in light and dark. All colors, spacing, sizing, and transitions are defined as CSS custom properties and can be overridden to match your brand.

The following properties control the tour’s appearance. Override them in your stylesheet or inline style.

Property Light Dark Purpose
--glow-tour-color-accent #4c35fd #6d5bff Primary action buttons and interactive elements
--glow-tour-color-accent-hover #3f2be0 #5d4bf0 Hover state of the advance button
--glow-tour-color-accent-active #3522c7 #4f3ce0 Active state of the advance button
--glow-tour-color-on-accent #ffffff #ffffff Text on top of the accent
--glow-tour-color-surface #ffffff #1c1c21 Popover background
--glow-tour-color-surface-muted #f6f6f7 #26262d Hover and disabled backgrounds
--glow-tour-color-text #1f1f23 #f2f2f4 Primary text color
--glow-tour-color-text-muted #5f5f66 #a8a8b3 Secondary text and muted content
--glow-tour-color-border #dedee3 #3a3a44 Popover border and dividers
--glow-tour-overlay-color #000000 #000000 Backdrop fill - see the note under Dark mode
Property Default Purpose
--glow-tour-spacing 8px Base spacing unit (buttons, gaps, padding)
--glow-tour-popover-width 352px Popover max-width
--glow-tour-control-height 32px Height of navigation buttons
--glow-tour-viewport-gap 16px Minimum gap from popover to viewport edges
Property Default Purpose
--glow-tour-radius 8px Border radius for popover and buttons
--glow-tour-shadow 0 4px 12px rgb(0 0 0 / 8%) in light, 0 8px 24px rgb(0 0 0 / 56%) in dark Popover box shadow
--glow-tour-transition-duration 120ms Hover/state color-transition duration for the Cancel/Previous/Advance buttons - not the popover’s fade/slide, which is a separate JS-driven animation (see the animation option, default 180ms, in the Builder reference)
--glow-tour-transition-easing ease-out Easing function for that same button color transition

The arrow is a rotated square drawn as a ::before pseudo-element on the popover. Unlike every other property on this page, these are injected by @glowhop/core-tour itself, so they apply with no stylesheet imported.

Property Default Purpose
--glow-tour-arrow-color --glow-tour-color-surface, else #ffffff Arrow fill, normally matching the popover background
--glow-tour-arrow-border-color --glow-tour-color-border, else #dedee3 Arrow border, normally matching the popover border
--glow-tour-arrow-border-width 1px Arrow border width
--glow-tour-arrow-border-radius 0px Rounding of the arrow tip
--glow-tour-arrow-size 12px Width and height of the square before rotation

--glow-tour-arrow-offset is not in that list: the core computes it on every reposition and writes it inline to centre the arrow on the target. It is an output, not a setting, and any value you assign is overwritten on the next frame.

To change the arrow’s shape rather than its values, set popover.arrow.autoStyles to false to skip the injected rules entirely and write your own. The popover carries a data-glow-tour-placement attribute, and the computed --glow-tour-arrow-offset, to position whatever you draw.

Override properties in a CSS file after importing the default theme. The tokens are declared at zero specificity on :where(:root), so an override on :root, on the tour root, or on any element that contains the tour all take precedence:

@import "@glowhop/styles-tour/default.css";
:where([data-glow-tour-root]) {
--glow-tour-color-accent: #00d9ff;
--glow-tour-color-surface: #1a1a2e;
--glow-tour-color-text: #f0f0f0;
--glow-tour-color-text-muted: #a0a0a8;
--glow-tour-color-border: #2a2a3e;
--glow-tour-radius: 12px;
--glow-tour-spacing: 12px;
}

Or in a regular CSS file:

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

The default theme ships both palettes. With nothing to configure, the tour follows the operating system preference through prefers-color-scheme.

To force one theme regardless of the OS - because your app has its own theme switch - set data-glow-tour-theme to light or dark:

<html data-glow-tour-theme="dark">

The attribute works on any element, not just :root. Tokens inherit, so putting it on <html> themes every tour on the page, while putting it on a wrapper themes only the tour inside it - which is what lets a single dark example sit on an otherwise light page:

<div data-glow-tour-theme="dark">
<GlowTourDefault tour={tour} />
</div>

The nearest ancestor carrying the attribute wins, so a wrapper can opt back into light inside a dark page.

What the dark palette changes, and what it does not

Section titled “What the dark palette changes, and what it does not”

Elevation moves from the shadow to the border. A drop shadow over a dark ground is close to invisible whatever its opacity, so in dark the border does the work of separating the popover from the page.

The backdrop keeps the same black fill in both themes - it dims the page, it does not tint it. Its opacity is written inline by the core on every frame and is therefore not reachable from CSS; if 70% black is too heavy over your dark UI, set it per step:

.step({ /* … */, overlay: { opacity: 0.5 } })

light-dark() resolves against the color-scheme property, which belongs to the host page: when color-scheme is unset - which is the case in most apps - it returns the light value even on a dark OS. Keying off a media query and an attribute of our own keeps the default correct without depending on a property your app uses for its own theme, and without raising the library’s browser floor.

Nothing stops you from using it in your tokens, though - you control your own :root, so the constraint does not apply:

:root {
color-scheme: light dark;
--glow-tour-color-surface: light-dark(#ffffff, #10101a);
--glow-tour-color-text: light-dark(#1f1f23, #f0f0f0);
}

The palettes are provided as a sensible default, not as a certified one. If you ship your own tokens, or place the tour over a busy background, check the contrast of --glow-tour-color-text, --glow-tour-color-text-muted and --glow-tour-color-on-accent against their surfaces yourself. See the accessibility notes for what the library does and does not guarantee.

To style a component on some steps only, give those steps classNames. The classes are added to the element the default theme styles, and removed when a step shows without them:

tour
.create("onboarding")
.step({
id: "danger-zone",
target: "#delete-account",
content: "This cannot be undone.",
classNames: { popover: "tour-danger", advance: "tour-danger-button" },
})
.build();
[data-glow-tour-popover].tour-danger {
--glow-tour-color-surface: #fff1f2;
--glow-tour-color-border: #e11d48;
}

The same option works on the workflow, for every step that does not set its own classes for the component, and in the JSON config. See Class name options.

When a step’s target is an async resolver - or the "wait" strategy is polling for one that isn’t in the DOM yet - the tour stays on the step the user asked to leave until the target settles. While it waits, that step’s popover carries data-glow-tour-awaiting-target and its advance button is disabled, which the default theme already dims. The attribute is a styling hook for anything more explicit, such as a spinner:

[data-glow-tour-popover][data-glow-tour-awaiting-target] [data-glow-tour-advance-trigger]::after {
content: "";
display: inline-block;
inline-size: 0.75em;
block-size: 0.75em;
margin-inline-start: 0.5em;
border: 2px solid currentColor;
border-block-start-color: transparent;
border-radius: 50%;
animation: glow-tour-spin 0.6s linear infinite;
}
@keyframes glow-tour-spin {
to {
transform: rotate(1turn);
}
}

A target that resolves synchronously never sets the attribute, so an instant transition does not flash a spinner. The same wait is readable from JavaScript as tour.state.get().awaitingTarget, for a spinner rendered by your own components rather than by CSS.

Tailwind CSS v4 generates its utility classes inside @layer utilities. A stylesheet imported outside any layer always wins over a layered one, whatever the specificity, so an unlayered default.css overrides the utilities you give a tour component: p-2 loses to the popover’s padding.

Import the theme in Tailwind’s components layer instead, from the same CSS file that imports Tailwind:

@import "tailwindcss";
@import "@glowhop/styles-tour/default.css" layer(components);

The theme’s rules then come before the utilities, and your classes take precedence:

<GlowTourPopover className="p-2 rounded-2xl">
<GlowTourHeader />
<GlowTourContent />
<GlowTourFooter className="gap-3" />
</GlowTourPopover>

The same applies to the classes a step adds with classNames:

.step({
id: "billing",
target: "#billing",
content: "Plans changed this month.",
classNames: { popover: "border-rose-500 bg-rose-50", advance: "bg-rose-600" },
})

Keep that stylesheet the only place the theme is imported. An import "@glowhop/styles-tour/default.css" left in a script loads a second, unlayered copy that wins over the utilities again.

Tailwind only generates the classes it finds in your source files. Class names that come from a JSON config loaded at runtime are not scanned: list them in your sources, or declare them with @source inline().

For complete control over the popover layout, header styling, or footer layout, you can use custom composition and write your own styles:

import { createGlowTour, GlowTourAdvanceTrigger, GlowTourCancelTrigger, GlowTourContent, GlowTourFooter, GlowTourHeader, GlowTourOverlay, GlowTourPointer, GlowTourPopover, GlowTourRoot } from "@glowhop/react-tour";
import "./custom-tour.css";
const tour = createGlowTour();
export function CustomStyledTour() {
return (
<GlowTourRoot tour={tour}>
<GlowTourOverlay />
<GlowTourPointer />
<GlowTourPopover className="my-custom-popover">
<GlowTourHeader className="my-custom-header" />
<GlowTourContent className="my-custom-content" />
<GlowTourFooter className="my-custom-footer">
<GlowTourCancelTrigger />
<GlowTourAdvanceTrigger />
</GlowTourFooter>
</GlowTourPopover>
</GlowTourRoot>
);
}

Then apply your styles:

.my-custom-popover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 20px;
}
.my-custom-header {
font-size: 1.25rem;
font-weight: 700;
}
.my-custom-footer {
gap: 12px;
}

The default theme uses CSS custom properties and :where() selectors for minimal specificity, making it easy to override.