|
| 1 | +--- |
| 2 | +ID: RFC 003 |
| 3 | +Created: June 19, 2026 |
| 4 | +Status: Draft |
| 5 | +RFC PR: https://github.com/raystack/apsara/pull/839 |
| 6 | +--- |
| 7 | + |
| 8 | +# Guided Tour Component |
| 9 | + |
| 10 | +This RFC proposes `Tour`, a component for building guided product walkthroughs: onboarding flows, feature coach marks, and step by step tutorials. It is built on Apsara's Base UI `Popover`, so it inherits our positioning engine, tokens, and composition style instead of pulling in an outside library. This describes the full intended component; a working prototype already validates the core (positioning, spotlight, controlled state, target waiting). |
| 11 | + |
| 12 | +## Table of Contents |
| 13 | + |
| 14 | +- [Guided Tour Component](#guided-tour-component) |
| 15 | + - [Table of Contents](#table-of-contents) |
| 16 | + - [Background](#background) |
| 17 | + - [Choosing an Approach: Library or Build Our Own](#choosing-an-approach-library-or-build-our-own) |
| 18 | + - [Adopt an Existing Library](#adopt-an-existing-library) |
| 19 | + - [Build Our Own on Base UI Popover](#build-our-own-on-base-ui-popover) |
| 20 | + - [Decision](#decision) |
| 21 | + - [Proposal](#proposal) |
| 22 | + - [Behavior and Features](#behavior-and-features) |
| 23 | + - [Parts and Context](#parts-and-context) |
| 24 | + - [State and Actions](#state-and-actions) |
| 25 | + - [Target Resolution](#target-resolution) |
| 26 | + - [Advancing Steps](#advancing-steps) |
| 27 | + - [Overlay and Spotlight](#overlay-and-spotlight) |
| 28 | + - [Positioning, Dismissal, and Z Index](#positioning-dismissal-and-z-index) |
| 29 | + - [Accessibility](#accessibility) |
| 30 | + - [Events](#events) |
| 31 | + - [Impact](#impact) |
| 32 | + - [Helpful Links](#helpful-links) |
| 33 | + |
| 34 | +## Background |
| 35 | + |
| 36 | +Guided tours are a common product need: onboarding a new user, introducing a feature, or walking through a multi step task all want the same UI, a sequence of small cards that point at real elements, dim or highlight their surroundings, and let the user move on, go back, or leave. Apsara has no tour primitive today, so the aim here is to solve it once, as a themeable and composable component. |
| 37 | + |
| 38 | +## Choosing an Approach: Library or Build Our Own |
| 39 | + |
| 40 | +The first decision is whether to wrap an existing tour library or build the primitive ourselves. |
| 41 | + |
| 42 | +### Adopt an Existing Library |
| 43 | + |
| 44 | +Wrapping a mature library (driver.js, Shepherd, React JoyRide and others) is the fastest path. |
| 45 | + |
| 46 | +- **Pros:** works on day one, battle tested across many apps, and usually ships extras like beacons and keyboard handling. |
| 47 | +- **Cons:** its own DOM and CSS that never match our tokens; a second positioning engine running next to Base UI's; another dependency to maintain; and little control over the behavior we need |
| 48 | + |
| 49 | +### Build Our Own on Base UI Popover |
| 50 | + |
| 51 | +A tour is really a popover that hops between anchors, plus a dimmed backdrop and a small step machine. Base UI's `Popover` already does the floating element logic, so the work left is the step machine, the spotlight, and the tour specific behavior. |
| 52 | + |
| 53 | +- **Pros:** native tokens and components, one positioning engine, our usual composition idioms, full control of dismissal, focus, and no new dependency. |
| 54 | +- **Cons:** we own the maintenance and it is more upfront work. |
| 55 | + |
| 56 | +### Decision |
| 57 | + |
| 58 | +Build our own. A tour sits on top of the whole app and has to feel like part of the product, which is exactly the design system's job. The recurring cost of overriding a library's styling and reconciling its positioning, focus, and stacking with ours outweighs the one time cost of building a focused component on a primitive we already maintain. |
| 59 | + |
| 60 | +## Proposal |
| 61 | + |
| 62 | +`Tour` is one compound component, built on Base UI `Popover` and exported from the package root. The parts: |
| 63 | + |
| 64 | +```tsx |
| 65 | +<Tour> // root: holds steps + open/index state, resolves targets, emits events, owns actions |
| 66 | + <Tour.Overlay/> // dimmed backdrop with a spotlight hole over the target |
| 67 | + <Tour.Popover> // the card: static children, a render function, or the default layout |
| 68 | + <Tour.Title/> <Tour.Description/> // fall back to step.title / step.content |
| 69 | + <Tour.Progress/> // "n of total", optional format() |
| 70 | + <Tour.Prev/> <Tour.Next/> <Tour.Skip/> <Tour.Close/> // run your onClick, then the action |
| 71 | + </Tour.Popover> |
| 72 | +</Tour> |
| 73 | +// useTour() exposes the same state and actions anywhere inside <Tour> |
| 74 | +``` |
| 75 | + |
| 76 | +A tour is data first: describe each step as an object and pass an array. |
| 77 | + |
| 78 | +```ts |
| 79 | +type TourTarget = string | Element | RefObject<Element | null> | (() => Element | null); |
| 80 | + |
| 81 | +interface TourStep { |
| 82 | + id?: string; |
| 83 | + target?: TourTarget; // what to anchor and spotlight; omit for a centered step |
| 84 | + title?: ReactNode; |
| 85 | + content?: ReactNode; |
| 86 | + |
| 87 | + side?: 'top' | 'right' | 'bottom' | 'left'; // default 'bottom' |
| 88 | + align?: 'start' | 'center' | 'end'; // default 'center' |
| 89 | + sideOffset?: number; |
| 90 | + |
| 91 | + spotlightTarget?: TourTarget; // spotlight something other than the anchor |
| 92 | + spotlightPadding?: number; |
| 93 | + spotlightRadius?: number; |
| 94 | + spotlightClicks?: boolean; // let clicks reach the highlighted element |
| 95 | + disableOverlay?: boolean; // override the tour-level overlay for this step |
| 96 | + |
| 97 | + scrollTarget?: TourTarget; // scroll a different element into view |
| 98 | + disableScroll?: boolean; // skip scrolling the target into view |
| 99 | + |
| 100 | + data?: unknown; // anything; echoed back to the render function and events |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +The root carries the tour wide options: |
| 105 | + |
| 106 | +```ts |
| 107 | +interface TourRootProps { |
| 108 | + steps: TourStep[]; |
| 109 | + |
| 110 | + open?: boolean; // controlled; or defaultOpen for uncontrolled |
| 111 | + defaultOpen?: boolean; |
| 112 | + onOpenChange?: (open: boolean, details: { status?: TourEndStatus }) => void; |
| 113 | + stepIndex?: number; // controlled; or defaultStepIndex |
| 114 | + defaultStepIndex?: number; |
| 115 | + onStepChange?: (index: number, step: TourStep) => void; |
| 116 | + |
| 117 | + onEvent?: (event: TourEvent) => void; |
| 118 | + actionsRef?: RefObject<TourActions | null>; |
| 119 | + |
| 120 | + targetTimeout?: number; // wait for a missing target, default 5000ms |
| 121 | + targetNotFound?: 'skip' | 'stop'; // default 'skip' |
| 122 | + |
| 123 | + disableOverlay?: boolean; // hide the dimmed overlay for the whole tour |
| 124 | + |
| 125 | + children?: ReactNode; // defaults to <Tour.Overlay/> + <Tour.Popover/> |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +The simplest tour is one array and the default card: |
| 130 | + |
| 131 | +```tsx |
| 132 | +const steps: TourStep[] = [ |
| 133 | + { target: '[data-test-id="search"]', title: 'Search', content: 'Find anything here.' }, |
| 134 | + { target: filtersRef, title: 'Filter', content: 'Narrow results.', side: 'right' }, |
| 135 | + { title: "You're all set!", content: 'Explore at your own pace.' }, // no target, centered |
| 136 | +]; |
| 137 | + |
| 138 | +// Renders the overlay + the standard card (Title + Close, Description, Progress, Back, Next/Finish). |
| 139 | +<Tour steps={steps} defaultOpen /> |
| 140 | +``` |
| 141 | + |
| 142 | +For tours that react to the app, control `open` and `stepIndex`, pass an `actionsRef`, and render your own card. The render function gets the active step plus `actions`: |
| 143 | + |
| 144 | +```tsx |
| 145 | +interface TourRenderProps { |
| 146 | + step: TourStep; |
| 147 | + index: number; |
| 148 | + totalSteps: number; |
| 149 | + isFirstStep: boolean; |
| 150 | + isLastStep: boolean; |
| 151 | + status: 'idle' | 'waiting' | 'running'; |
| 152 | + actions: TourActions; // start, next, prev, go, skip, stop |
| 153 | +} |
| 154 | + |
| 155 | +const actionsRef = useRef<TourActions>(null); |
| 156 | + |
| 157 | +<Button onClick={() => actionsRef.current?.start()}>Take a tour</Button> |
| 158 | + |
| 159 | +<Tour |
| 160 | + steps={steps} |
| 161 | + open={open} |
| 162 | + stepIndex={index} |
| 163 | + actionsRef={actionsRef} |
| 164 | + onOpenChange={(open, { status }) => { setOpen(open); if (!open) track('tour_end', status); }} |
| 165 | + onStepChange={setIndex} |
| 166 | + disableOverlay |
| 167 | +> |
| 168 | + <Tour.Popover> |
| 169 | + {({ step, index, totalSteps, isLastStep, actions }) => ( |
| 170 | + <Flex direction="column" gap={3}> |
| 171 | + <Text weight="medium">{step.title}</Text> |
| 172 | + <Text variant="secondary">{step.content}</Text> |
| 173 | + <Flex justify="between" align="center"> |
| 174 | + <Text size="mini">{index + 1} of {totalSteps}</Text> |
| 175 | + <Button size="small" onClick={isLastStep ? actions.stop : actions.next}> |
| 176 | + {isLastStep ? 'Done' : 'Next'} |
| 177 | + </Button> |
| 178 | + </Flex> |
| 179 | + </Flex> |
| 180 | + )} |
| 181 | + </Tour.Popover> |
| 182 | +</Tour> |
| 183 | +``` |
| 184 | + |
| 185 | +## Behavior and Features |
| 186 | + |
| 187 | +### Parts and Context |
| 188 | + |
| 189 | +```tsx |
| 190 | +export const Tour = Object.assign(TourRoot, { |
| 191 | + Overlay, Popover, Title, Description, Progress, Next, Prev, Skip, Close, |
| 192 | +}); |
| 193 | +``` |
| 194 | + |
| 195 | +The root provides a context; each part reads `step`, `anchor`, `status`, and `actions` from it with `useTourContext` (and throws if used outside `<Tour>`). Because parts take their data from context rather than props, a consumer can reorder, drop, or replace any of them. |
| 196 | + |
| 197 | +### State and Actions |
| 198 | + |
| 199 | +`open` and `stepIndex` each run through Base UI's `useControlled`, so either can be controlled or uncontrolled independently. The index is clamped to the step range, and `actions` keeps a stable identity across renders, so it is safe in `actionsRef` and effect dependencies. |
| 200 | + |
| 201 | +```ts |
| 202 | +interface TourActions { |
| 203 | + start: (index?: number) => void; // the only action that runs while closed |
| 204 | + next: () => void; // finishes the tour past the last step |
| 205 | + prev: () => void; |
| 206 | + go: (index: number) => void; |
| 207 | + skip: () => void; // ends with status 'skipped' |
| 208 | + stop: () => void; // ends with status 'closed' |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +### Target Resolution |
| 213 | + |
| 214 | +```ts |
| 215 | +target: '#search' // CSS selector |
| 216 | +target: searchRef // React ref |
| 217 | +target: () => editor.getNode('aoi') // function returning an element |
| 218 | +target: document.body // an element |
| 219 | +// omit target → a detached step, centered in the viewport |
| 220 | +``` |
| 221 | + |
| 222 | +`useTourTarget` resolves immediately when the element is connected. Otherwise it watches `document.body` with a `MutationObserver` and resolves the moment the target appears, giving up after `targetTimeout` and applying `targetNotFound`. While it waits, `status` is `'waiting'`. |
| 223 | + |
| 224 | +### Advancing Steps |
| 225 | + |
| 226 | +The default card's Next button calls `actions.next()`. For a step that should advance when the user does something on the page (draw a shape, open a panel), render your own card with a render function, leave out Next, and call `actions.next()` or `actions.go()` from app code when the relevant state changes. |
| 227 | + |
| 228 | +```tsx |
| 229 | +// advance the draw step once the app reports a shape on the map |
| 230 | +useEffect(() => { |
| 231 | + if (open && stepIndex === 2 && store.hasShape) actionsRef.current?.go(3); |
| 232 | +}, [open, stepIndex, store.hasShape]); |
| 233 | +``` |
| 234 | + |
| 235 | +### Overlay and Spotlight |
| 236 | + |
| 237 | +The dimming is the box shadow of the spotlight element, not a separate scrim: |
| 238 | + |
| 239 | +```css |
| 240 | +.spotlight { box-shadow: 0 0 0 200vmax var(--rs-color-overlay-black-a5); } |
| 241 | +``` |
| 242 | + |
| 243 | +The transparent `.spotlight` div sits over the target, so its shadow dims everything else and moving it animates the hole. Hit strips block clicks around the hole (the strip over the hole is dropped when `spotlightClicks` is set), and a `requestAnimationFrame` loop keeps the hole on the target through scroll, resize, and transforms. |
| 244 | + |
| 245 | +### Positioning, Dismissal, and Z Index |
| 246 | + |
| 247 | +`Tour.Popover` is a `Popover.Root` (`modal={false}`) wrapping `Portal`, `Positioner`, and `Popup`, and takes `side`, `align`, `sideOffset`, and `showArrow` defaults that a step's `side`/`align`/`sideOffset` override. It anchors to the target, or for a detached step to a virtual viewport center anchor. Only escape closes the tour; outside clicks and focus moves are ignored, so a step can keep focus on the element it highlights. The overlay and positioner sit one and two layers above `--rs-z-index-portal`, so a step can spotlight content inside a dialog. |
| 248 | + |
| 249 | +### Accessibility |
| 250 | + |
| 251 | +The card is a Base UI `Popover`, so `Tour.Title` and `Tour.Description` give it an accessible name and description and focus is handled by the popover. Escape ends the tour, and animations respect reduced motion. |
| 252 | + |
| 253 | +### Events |
| 254 | + |
| 255 | +```tsx |
| 256 | +<Tour |
| 257 | + steps={steps} |
| 258 | + onEvent={(e) => { |
| 259 | + if (e.type === 'error:target-not-found') console.warn('missing target', e.index); |
| 260 | + if (e.type === 'tour:end') analytics.track('tour_end', { status: e.status }); |
| 261 | + }} |
| 262 | +/> |
| 263 | +``` |
| 264 | + |
| 265 | +```ts |
| 266 | +type TourEvent = |
| 267 | + | { type: 'tour:start'; index: number; step?: TourStep } |
| 268 | + | { type: 'step:active'; index: number; step: TourStep } |
| 269 | + | { type: 'error:target-not-found'; index: number; step: TourStep } |
| 270 | + | { type: 'tour:end'; index: number; status: 'finished' | 'skipped' | 'closed' }; |
| 271 | +``` |
| 272 | + |
| 273 | +The `tour:end` status says how it closed: `next()` past the last step is `finished`, `skip()` is `skipped`, and `stop()`, escape, or a missing target under the `stop` policy is `closed`. The same status reaches `onOpenChange`. |
| 274 | + |
| 275 | +## Impact |
| 276 | + |
| 277 | +- New exports: `Tour`, `useTour`, and the types `TourActions`, `TourEndStatus`, `TourEvent`, `TourRenderProps`, `TourStep`, `TourTarget`. Nothing else changes and nothing is deprecated. |
| 278 | +- Consumers get onboarding and coach marks from a single `steps` array, with a clear path to more control through composed parts and the render function. |
| 279 | +- It keeps bundle size, theming, and stacking under our control instead of an outside library's. |
| 280 | +- A prototype validates the core (positioning, spotlight, controlled state, target waiting, an overlay free action gated tour driven through `actions.go()`); this RFC specifies the complete component. |
| 281 | + |
| 282 | +## Helpful Links |
| 283 | + |
| 284 | +- [Base UI, Popover](https://base-ui.com/react/components/popover). The primitive the tour is built on. |
| 285 | +- [driver.js](https://driverjs.com/) and [Shepherd](https://shepherdjs.dev/). External tour libraries weighed under Choosing an Approach. |
| 286 | +- [WAI-ARIA Authoring Practices, Dialog (Modal) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). Informs focus and labeling for the card. |
| 287 | +- Internal: `packages/raystack/components/tour/ANALYSIS.md`, the prototype analysis backing this RFC. |
| 288 | +- Internal: `apps/www/src/app/examples/tour/page.tsx`, the prototype example. |
0 commit comments