Tour
Compound primitive for guided onboarding — sequence, DOM-id targeting, lifecycle hooks, and styled wrappers.
The Tour compound from @workspace/ui builds guided product tours: a numbered sequence of DOM elements, a spotlight overlay, a popover anchored to each step, and prev/next/skip controls. The primitive owns no styling and no persistence — wrappers in @workspace/ui/components/tour provide the design-system look, and the consumer decides whether to remember "already seen".
When to use it
- Onboarding on a complex page (editor, dashboard).
- One-off feature spotlight after release.
- Any UX where the user needs to be walked through a sequence of UI targets.
For a single contextual tooltip, prefer Popover or HoverCard. Tour is built around an ordered sequence with prev/next navigation.
Principles
- Targets are matched by HTML
id— every step'svaluemust equal theidof the element to highlight. The primitive callsdocument.getElementById(value)on each step to compute the spotlight rectangle. - The Root owns sequence and active state —
Tour.Rootacceptssequence: string[]and exposesactive/onActiveChange(controlled, or uncontrolled viadefaultActive). - Composition is required — sub-components (
Overlay,Content,Next,Previous,Skip,Trigger) are placed and styled freely. Consumers arrange them as they like. - Persistence is external — the primitive does not know whether the user already saw the tour. Listen to
onActiveChangeand store the flag yourself (localStorage, server, query…). - Body scroll is locked while active — the primitive handles the lock and the scrollbar-width compensation. No need to wire that up downstream.
API surface
import { Tour } from "@workspace/ui/components/tour";
Tour.Root // Container — owns sequence + active state
Tour.Overlay // Dark overlay with spotlight clip-path on the target
Tour.Content // Popover anchored to id={value}, with collision avoidance
Tour.Trigger // Button calling start() on click
Tour.Next // Next button, disabled when isLast
Tour.Previous // Previous button, disabled when isFirst
Tour.Skip // Skip button calling end() on click
Tour.useTour() // Read state + actions inside Root
Tour.useTourStep(value, lifecycle) // Register lifecycle hooks for a stepTour.Root
type RootProps = {
sequence: string[]; // Step order — also the DOM ids
active?: boolean; // Controlled
defaultActive?: boolean; // Uncontrolled
onActiveChange?: (active: boolean) => void;
value?: string; // Current step (controlled)
defaultValue?: string; // First step by default (uncontrolled)
onValueChange?: (value: string) => void;
config?: TourConfig; // Override defaults (padding, offsets…)
children: ReactNode;
};
type TourConfig = {
spotlightPadding?: number; // Padding around target (default: 8)
visibilityMargin?: number; // Viewport margin for visibility check (default: 20)
scrollAdjustment?: number; // Extra scroll to nudge popover into view (default: 100)
maxScrollAttempts?: number; // Max auto-scroll retries (default: 3)
sideOffset?: number; // Gap between target and popover (default: 16)
};Tour.useTour()
const {
active, // Boolean — tour is running
value, // string — current step (= DOM id)
sequence, // string[]
index, // number — current step index
total, // number — sequence.length
isFirst, // boolean
isLast, // boolean
next, // () => void
previous, // () => void
start, // () => void
end, // () => void
goTo, // (value: string) => void — direct jump
} = Tour.useTour();Tour.useTourStep(value, lifecycle)
Attach async hooks to a step — useful for opening a panel, fetching data, or scrolling before the spotlight settles.
Tour.useTourStep("step-bidding", {
beforeEnter: async () => openBiddingPanel(), // Before setValue moves to this step
afterEnter: async () => focusFirstBid(), // After
beforeLeave: async () => undefined, // Before leaving this step
afterLeave: async () => closeBiddingPanel(), // After
});Transitions are serialized — if beforeEnter is async, the primitive awaits it before flipping value to the new step.
Quick start
A minimal three-step tour:
import { Tour } from "@workspace/ui/components/tour";
function MyOnboarding() {
return (
<Tour.Root sequence={["intro", "settings", "save"]} defaultActive>
<Tour.Overlay />
<Tour.Content value="intro" side="bottom">
<p>Welcome.</p>
<Tour.Skip>Skip</Tour.Skip>
<Tour.Next>Next</Tour.Next>
</Tour.Content>
<Tour.Content value="settings" side="right">
<p>Configure your preferences here.</p>
<Tour.Previous>Back</Tour.Previous>
<Tour.Next>Next</Tour.Next>
</Tour.Content>
<Tour.Content value="save" side="top">
<p>Click here to save.</p>
<Tour.Previous>Back</Tour.Previous>
<Tour.Skip>Finish</Tour.Skip>
</Tour.Content>
</Tour.Root>
);
}
// Somewhere on the page:
<button id="intro">Get started</button>
<form id="settings">…</form>
<button id="save">Save</button>The HTML id is the only link between a step and its DOM target. No React anchors, no shared refs — just id (which keeps targeting simple across deeply-nested component trees).
Styled wrappers
@workspace/ui/components/tour exposes ready-to-use wrappers that combine the primitive with the design system (Button, Separator, motion):
import {
TourContent, // Styled popover with framer-motion entrance
TourOverlayStyled, // bg-black/60 overlay
TourNext, TourPrevious, TourSkip, // Design-system buttons
TourTriggerStyled, // Ghost-style trigger button
TourNavigation, // "skip · prev · next" layout + step label
TourAlertDialog, // Welcome dialog (pre-start)
TourStepContent, // Drop-in popover (title + description + nav)
} from "@workspace/ui/components/tour";Reach for these first; drop down to the primitive (Tour.Root, Tour.Content…) only when you need a custom look.
Persistence ("already seen")
The primitive persists nothing. The consumer:
- Stores a flag (localStorage, server, query) signalling whether the user has seen the tour.
- Decides at mount whether to show the welcome dialog or auto-start.
- Listens to
onActiveChangeand flips the flag totruewhen the tour ends (skip or completion).
Reference pattern:
const [hasSeenTour, setHasSeenTour] = useState(() => {
if (typeof window === "undefined") return true;
return localStorage.getItem("my-feature-tour-seen") === "true";
});
const onTourSeen = useCallback(() => {
setHasSeenTour(true);
localStorage.setItem("my-feature-tour-seen", "true");
}, []);
const handleActiveChange = useCallback((active: boolean) => {
if (!active) onTourSeen();
}, [onTourSeen]);
return (
<Tour.Root sequence={sequence} onActiveChange={handleActiveChange}>
{/* … */}
</Tour.Root>
);localStorage is enough for onboarding — accept that switching device replays the tour.
Reference: the deal editor
The deal editor (apps/app/src/features/deals/components/editor/tour/) is the canonical implementation:
DealTourProvider(DealTour.tsx) — wrapsTour.Root, picks the sequence based onuseMediaQuery("(max-width: 1023px)")(desktop vs mobile). No persistence here.DealTourOverlay(DealTour.tsx) — rendersTourOverlayStyledplus aDealTourStepContentper step (oneTourContenteach, withuseTourStepTranslationfor the label).DealTourDialog(DealTour.tsx) — theTourAlertDialogwelcome.DealTourTrigger(DealTour.tsx) — the styled "?" helper button to restart the tour on demand.- Persistence in
UpsertFormProvider(upsert-form-provider.tsx) —hasSeenTouris local state seeded fromlocalStorage["bridge-editor-tour-seen"];handleActiveChangeupdates it when the tour closes. - DOM targets are tagged with
id="upload",id="board-hands", etc. inside the editor's components; ids match the entries ingetDesktopSequence()/getMobileSequence()inuseTourSteps.ts.
Recommended layout for a new consumer:
features/{feature}/tour/
├── {Feature}Tour.tsx Provider + Overlay + Dialog + Trigger
├── useTourSteps.ts Sequences (desktop/mobile) + translation keys
└── index.ts ExportsPersistence stays in the parent feature provider (form, page) — not in the tour/ sub-folder, so the folder remains stateless and easy to delete when the tour is retired.
Pitfalls
idvalues must be unique on the rendered page. If two components shareid="board-hands"(e.g. an open dialog re-mounts an editor), the spotlight target is undefined. Prefix ids (tour-board-hands) when the target lives in a shared layout.- The target must be in the DOM at step time. If the target is conditional (mobile-only, behind a toggle), use
Tour.useTourStepwithbeforeEnterto open the toggle before the lookup runs. - The internal
MutationObservertracks repositioning but is not free on heavy pages. For animation-heavy UIs, raiseconfig.scrollAdjustmentinstead of relying on extra auto-scroll passes. - The body scroll lock removes scrollbars. If the layout depends on scrollbar width (sticky headers compensating padding), test the active state visually.