Skip to content

Commit 75c5cc1

Browse files
feat: RFC Tour component (#839)
* feat: tour rfc and example * feat: tour rfc and example * feat: update example and rfc
1 parent 51d18b2 commit 75c5cc1

16 files changed

Lines changed: 2348 additions & 1 deletion

File tree

apps/www/src/app/examples/tour/page.tsx

Lines changed: 474 additions & 0 deletions
Large diffs are not rendered by default.

docs/rfcs/002-unified-dataview-component.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
ID: RFC 002
33
Created: April 23, 2026
4-
Status: Draft
4+
Status: Completed
55
RFC PR: https://github.com/raystack/apsara/pull/752
66
---
77

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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

Comments
 (0)