Bridge Training
Design system

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

  1. Targets are matched by HTML id — every step's value must equal the id of the element to highlight. The primitive calls document.getElementById(value) on each step to compute the spotlight rectangle.
  2. The Root owns sequence and active state — Tour.Root accepts sequence: string[] and exposes active / onActiveChange (controlled, or uncontrolled via defaultActive).
  3. Composition is required — sub-components (Overlay, Content, Next, Previous, Skip, Trigger) are placed and styled freely. Consumers arrange them as they like.
  4. Persistence is external — the primitive does not know whether the user already saw the tour. Listen to onActiveChange and store the flag yourself (localStorage, server, query…).
  5. 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 step

Tour.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:

  1. Stores a flag (localStorage, server, query) signalling whether the user has seen the tour.
  2. Decides at mount whether to show the welcome dialog or auto-start.
  3. Listens to onActiveChange and flips the flag to true when 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) — wraps Tour.Root, picks the sequence based on useMediaQuery("(max-width: 1023px)") (desktop vs mobile). No persistence here.
  • DealTourOverlay (DealTour.tsx) — renders TourOverlayStyled plus a DealTourStepContent per step (one TourContent each, with useTourStepTranslation for the label).
  • DealTourDialog (DealTour.tsx) — the TourAlertDialog welcome.
  • DealTourTrigger (DealTour.tsx) — the styled "?" helper button to restart the tour on demand.
  • Persistence in UpsertFormProvider (upsert-form-provider.tsx) — hasSeenTour is local state seeded from localStorage["bridge-editor-tour-seen"]; handleActiveChange updates 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 in getDesktopSequence() / getMobileSequence() in useTourSteps.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                Exports

Persistence 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

  • id values must be unique on the rendered page. If two components share id="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.useTourStep with beforeEnter to open the toggle before the lookup runs.
  • The internal MutationObserver tracks repositioning but is not free on heavy pages. For animation-heavy UIs, raise config.scrollAdjustment instead 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.