A small headless React library for guided product tours. Steps register from the React tree (no DOM selectors), the library handles positioning and lifecycle, you bring the styling.
npm install @echoeyecodes/simple-tour @radix-ui/react-slotreact, react-dom, and @radix-ui/react-slot are peer dependencies.
import {
TourProvider,
TourPortal,
TourHighlight,
TourContent,
TourTrigger,
TourNext,
TourPrevious,
TourSkip,
useTourStep,
useTourActive,
} from "@echoeyecodes/simple-tour";
function Page() {
const buttonRef = useTourStep({
id: "publish",
order: 1,
placement: "bottom",
content: (
<div>
<h3>Publish</h3>
<p>When everything looks right, hit Publish.</p>
</div>
),
});
return (
<div>
<TourTrigger>Take a tour</TourTrigger>
<button ref={buttonRef}>Publish</button>
</div>
);
}
export function App() {
return (
<TourProvider>
<Page />
<TourPortal>
<TourHighlight className="[--tour-pad:8px] ..." />
<TourContent className="...">
<ActiveStepContent />
<div>
<TourSkip>Skip</TourSkip>
<TourPrevious>Back</TourPrevious>
<TourNext>Next</TourNext>
</div>
</TourContent>
</TourPortal>
</TourProvider>
);
}
function ActiveStepContent() {
const { step } = useTourActive();
return <>{step.contentRef.current}</>;
}useTourStep({ id, order, placement, content }) returns a RefCallback<HTMLElement>. Attach it to whatever element should be highlighted. The step is registered on mount and removed on unmount, so steps living inside conditionally rendered components — modals, tabs, route children — are handled naturally.
TourPortal only mounts its children when (a) a tour is active, (b) the active step is registered, and (c) its target element has a measurable rect. Anything inside TourPortal can use useTourActive() to read the current step and target rect — no nullable state to handle.
TourHighlight, TourContent, TourSkip, TourPrevious, TourNext, TourTrigger ship with no visual styling. They expose className, style, and asChild (via @radix-ui/react-slot). Compose them with your own components.
| Primitive | What it does |
|---|---|
TourProvider |
State + context. Wrap your app (or part of it). |
TourPortal |
Renders children to document.body. Only when active. Handles scroll-into-view. |
TourHighlight |
A positioned element matching the target rect. Set --tour-pad to expand it outward (e.g. className="[--tour-pad:8px]"). |
TourContent |
A positioned tooltip near the target. Auto-flips placement on overflow. |
TourTrigger |
A button that calls start(). |
TourNext / TourPrevious / TourSkip |
Buttons wired to the corresponding control. |
useTour(); // { active, currentStepIndex, totalSteps, isFirst, isLast, start, next, previous, end, goTo }
useTourActive(); // { step, rect } — only valid inside <TourPortal>
useTourStep({ ... }) // ref callback to attach to the target elementIf you call next() while the next step's element isn't mounted yet (e.g. it lives on a different route), the tour pauses. Once the element registers, the tooltip appears. Nothing to wire up.
bun install
bun run buildOutputs ESM + CJS + types into dist/.
MIT