Product tours that wear
your design system
All the logic — targeting, positioning, animated spotlight, keyboard navigation — none of the opinions. Restyle it with CSS variables, class names, or replace every component with your own.
Live playground
Why another tour library?
Most tour packages ship their own look and fight your theme. This one is headless: it does the hard parts and stays out of your stylesheet.
Three levels of theming
CSS variables → class names → replace whole components. Your design system wins.
Springy spotlight
An SVG-mask cutout glides smoothly between targets — pure CSS, no animation library.
Bulletproof positioning
Floating UI under the hood — auto flip, shift and arrow. No clipped tooltips.
Keyboard navigation
Arrow keys, Enter and Escape work out of the box. Fully optional.
Interactable steps
Let users click the highlighted element mid-tour — great for guided actions.
Follows moving targets
Survives scrolling, resizes, sticky headers and layout shifts via live tracking.
Next.js ready
SSR-safe, ships "use client" for the App Router. ESM + CJS + full types.
Tiny
~4.5 kB min+gzip of library code. One provider, one hook, one dependency.
Size matters
Every kilobyte you ship is loading time your users pay for a feature they see once.
| Library | Bundle cost (min+gzip) | React components | Theming | License |
|---|---|---|---|---|
| react-headless-tour | 12.3 kB (4.5 kB core) | ✅ React | ✅ Fully headless | MIT |
| react-joyride | 25.0 kB | ✅ React | ⚠️ Styled, override via props | MIT |
| intro.js | 17.2 kB | ❌ Vanilla + wrapper | ⚠️ Theme via CSS overrides | AGPL / paid commercial |
| driver.js | 7.2 kB | ❌ Vanilla | ⚠️ Theme via CSS overrides | MIT |
Sizes measured via bundlephobia, September 2026. “Core” excludes the Floating UI positioning engine (shared with many popover/tooltip libraries, so you may already ship it).
Documentation
The essentials — the full API reference lives in the README.
1 · Quick start
import { TourProvider, useTour, type TourStep } from "react-headless-tour";
const steps: TourStep[] = [
{ title: "Welcome 👋", content: "No target = centered modal step." },
{ target: "#stats", title: "Your metrics", content: "At a glance." },
{ target: "#new", title: "Create one", placement: "left", interactable: true },
];
function StartButton() {
const { start } = useTour();
return <button onClick={() => start()}>Start tour</button>;
}
export default function App() {
return (
<TourProvider steps={steps}>
<StartButton />
{/* ...your app... */}
</TourProvider>
);
}2 · Theme with CSS variables (zero code)
Every visual decision of the default card is a --tour-* variable with a sensible fallback. Scope them under a class and pass it as classNames={{ root: "my-tour" }}, or inline via the theme prop:
.my-tour {
--tour-bg: #16181d; /* card background */
--tour-fg: #f9fafb; /* card text */
--tour-accent: #4f46e5; /* primary button */
--tour-radius: 14px;
--tour-overlay-color: rgba(10, 12, 24, 0.68);
}3 · Or bring your own components (fully headless)
Replace the card entirely with components={{ Card }}. You receive the whole tour state and controls as typed props — the library keeps handling positioning, the spotlight and animations. The gradient “Brand” flavor in the playground above is exactly this.
import type { TourCardProps } from "react-headless-tour";
function MyCard({ step, next, prev, stop, isLast, arrow }: TourCardProps) {
return (
<div className="my-design-system-popover">
{arrow /* optional — skip it for an arrowless design */}
<h3>{step.title}</h3>
<p>{step.content}</p>
<button onClick={next}>{isLast ? "Done" : "Next"}</button>
</div>
);
}
<TourProvider steps={steps} components={{ Card: MyCard }} />API reference
Everything below is fully typed — your editor shows these same descriptions via IntelliSense.
<TourProvider> props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | TourStep[] | — | The tour definition. Required. |
| autoStart | boolean | false | Start the tour automatically on mount. |
| stepIndex | number | — | Controlled mode: you own the active step index. Pair with onStepChange. |
| onStepChange | (index, step) => void | — | Fires whenever the active step changes (buttons, keyboard or goTo). |
| onStart | () => void | — | Fires when the tour starts. |
| onStop | (reason, lastIndex) => void | — | Fires when the tour ends. reason: "finished" | "skipped" | "escape" | "mask" | "programmatic". |
| components | { Card?, Arrow? } | — | Replace the card and/or arrow with your own components (fully headless). |
| classNames | TourClassNames | — | Class hooks for every rendered part — style with Tailwind, CSS Modules, anything. |
| theme | { "--tour-*": value } | — | Inline CSS-variable overrides, applied on the tour root so they always win. |
| overlayColor | string | rgba(0,0,0,0.55) | Dimming color. Also themeable via --tour-overlay-color. |
| overlayBlur | number | 0 | Frosted-glass blur behind the overlay, in px. |
| showOverlay | boolean | true | Render the dimming overlay at all. |
| closeOnMaskClick | boolean | false | Clicking the dimmed area stops the tour. |
| keyboard | boolean | true | ← → navigate, Enter advances, Esc stops. |
| spotlightPadding | number | 8 | Space between the target and the spotlight edge, in px. |
| spotlightRadius | number | 8 | Corner radius of the spotlight hole, in px. |
| offset | number | 12 | Gap between the target and the popover, in px. |
| lockScroll | boolean | false | Lock body scroll while the tour is active. |
| scrollIntoViewOptions | ScrollIntoViewOptions | smooth / center | How targets are auto-scrolled into view. |
| portalContainer | Element | document.body | Where the tour UI is portaled. |
| zIndex | number | 10000 | z-index of the tour root. Also --tour-z-index. |
| labels | { next?, prev?, finish?, skip?, progress? } | — | Texts for the default card — plain strings or nodes, i18n-ready. |
TourStep
| Field | Type | Default | Description |
|---|---|---|---|
| target | string | () => Element | null | — | CSS selector or resolver for the element to highlight. Omit for a centered modal step. |
| title | ReactNode | — | Heading shown by the default card (custom cards receive the whole step). |
| content | ReactNode | — | Body shown by the default card. |
| placement | "top" | "bottom" | "left" | "right" (+ -start / -end) | "bottom" | Preferred popover side. Flips and shifts automatically when space runs out. |
| interactable | boolean | false | Let the user click/type on the highlighted element during the step. |
| spotlightPadding | number | provider value | Per-step override of the spotlight padding. |
| spotlightRadius | number | provider value | Per-step override of the spotlight corner radius. |
| disableScroll | boolean | false | Skip auto-scrolling the target into view for this step. |
| data | unknown | — | Free-form payload passed through to a custom Card. |
| onEnter / onExit | (step, index) => void | — | Lifecycle hooks — e.g. navigate to another page in onEnter for multi-page tours. |
useTour()
Call it anywhere under the provider — ideal for “restart tour” menu items or driving the tour from app logic. Custom Cards receive all of this as props too.
| Member | Type | Description |
|---|---|---|
| isActive | boolean | Whether the tour is running. |
| stepIndex / totalSteps | number | Active step index (-1 when off) and total step count. |
| step | TourStep | null | The active step object. |
| isFirst / isLast | boolean | Position helpers for building custom cards. |
| start(atIndex?) | function | Start the tour, optionally at a specific step. |
| stop(reason?) | function | Stop the tour. |
| next() / prev() / goTo(i) | function | Navigate between steps. |
CSS variables
All accepted by the default card and overlay: --tour-bg, --tour-fg, --tour-muted, --tour-accent, --tour-accent-fg, --tour-radius, --tour-shadow, --tour-border, --tour-font, --tour-max-width, --tour-padding, --tour-arrow-size, --tour-overlay-color, --tour-z-index