Coachmark · Tour Composition
Two layers, deliberately. A Coachmark is one anchored bubble pointing at one element. A Tour sequences them. Neither remembers anything — whether a person has seen a tour is answered by a Seen Store your app injects.
Coachmark alone
const [anchor, setAnchor] = useState<Element | null>(null);
<button ref={setAnchor}>Settings</button>
<Coachmark open={open} anchor={anchor} title="Make it yours" onDismiss={close}> Theme, motion, and notification preferences live here.</Coachmark>The spotlight is a hole cut in a dimmed page, and it tracks the anchor through scroll and resize — a bubble whose hole has drifted off its target is worse than no bubble at all.
Tour
const steps: TourStep[] = [ { target: '#search', title: 'Find anything', content: 'Search across everything.' }, { target: '#alerts', title: 'Stay in the loop', content: 'Alerts collect here.' },];
<Tour id="first-run" steps={steps} open={running} onOpenChange={setRunning} seenStore={store} />The Seen Store
The library ships this interface and no implementation. A library that reaches for localStorage
has quietly decided what a user is and where their state lives — and that is the assumption that
makes an onboarding library impossible to remove later.
// localStorage — four linesconst store: SeenStore = { has: (id) => localStorage.getItem(`tour:${id}`) === 'seen', mark: (id) => localStorage.setItem(`tour:${id}`, 'seen'),};
// or per account, server-side — the Tour does not care whichconst store: SeenStore = { has: (id) => fetch(`/api/tours/${id}`).then((r) => r.json()).then((d) => d.seen), mark: (id) => void fetch(`/api/tours/${id}`, { method: 'POST' }),};Both endings mark the store: finishing and skipping alike. Someone who dismissed your tour has seen it, and showing it again is how a helpful feature becomes an annoying one.
Do
Give the tour a stable id and let the app decide where 'seen' is stored.
Don't
Start a tour on every mount without a store — a returning person meets it forever.
Tour props
| Prop | Type | Default | Notes |
|---|---|---|---|
| id | string | — | Stable identifier — this is what the Seen Store remembers. |
| steps | TourStep[] | — | target, title, content, side, align, spotlight. Targets may be a selector, an element, a ref, or a callback. |
| open | boolean | false | Starts the tour when it turns true. |
| onOpenChange | (open: boolean) => void | — | Called with false when the tour ends, either way. |
| seenStore | SeenStore | — | Injected memory. Sync or async — the Tour waits before showing anything. |
| onFinish / onSkip | () => void | — | Which ending happened. |
| labels | { back, next, done, skip } | — | Button copy, for products that do not speak English. |
Coachmark props
| Prop | Type | Default | Notes |
|---|---|---|---|
| open | boolean | — | Controlled — a Coachmark has no trigger of its own. |
| anchor | Element | null | — | Already resolved. A Tour does the resolving, including retrying while the target mounts. |
| title | ReactNode | — | One line. If it needs two, the step is doing too much. |
| side / align | Side | Align | 'bottom' / 'center' | Placement, with collision handling from Base UI. |
| spotlight | boolean | number | true | A number sets the padding around the hole, in px. |
| progress | { current, total } | — | Rendered as "2 of 5". |
| actions / onDismiss | ReactNode | () => void | — | Footer buttons and the close affordance. |
Edge cases the Tour handles
- A target that mounts late is retried for about half a second before the step gives up — a target that appears one frame after the tour starts is normal, not an error.
- A step list that changes mid-tour cannot leave the index pointing past the end.
- Reduced motion jumps the spotlight to the next target instead of gliding it there.