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

tsx
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

tsx
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.

tsx
// 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

PropTypeDefaultNotes
idstringStable identifier — this is what the Seen Store remembers.
stepsTourStep[]target, title, content, side, align, spotlight. Targets may be a selector, an element, a ref, or a callback.
openbooleanfalseStarts the tour when it turns true.
onOpenChange(open: boolean) => voidCalled with false when the tour ends, either way.
seenStoreSeenStoreInjected memory. Sync or async — the Tour waits before showing anything.
onFinish / onSkip() => voidWhich ending happened.
labels{ back, next, done, skip }Button copy, for products that do not speak English.

Coachmark props

PropTypeDefaultNotes
openbooleanControlled — a Coachmark has no trigger of its own.
anchorElement | nullAlready resolved. A Tour does the resolving, including retrying while the target mounts.
titleReactNodeOne line. If it needs two, the step is doing too much.
side / alignSide | Align'bottom' / 'center'Placement, with collision handling from Base UI.
spotlightboolean | numbertrueA number sets the padding around the hole, in px.
progress{ current, total }Rendered as "2 of 5".
actions / onDismissReactNode | () => voidFooter 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.