Coachmarks & tours
Anchored guided tours that survive DOM churn — multi-strategy anchor resolution, a fallback ladder that never dead-ends, branching, and per-step analytics.
Coachmarks point at real elements in your product. The hard part is not drawing a tooltip — it is still finding the element three releases later, after the class names changed and the button moved inside a new wrapper.
There is no feature flag. The engine loads lazily the first time you call
ScaleBun.coachmarks(), so it costs nothing until you use it.
Start a tour#
const coach = ScaleBun.coachmarks();
// A tour authored in the dashboardcoach.startById('onboarding');
// …or one defined in codecoach.start({ id: 'checkout-intro', steps: [ { id: 'cart', anchor: { strategies: [{ kind: 'data-attr', value: 'cart' }] }, title: 'Your cart', body: 'Everything you have added lives here.', advance: 'anchor-click', }, { id: 'done', title: 'That is it', body: 'Happy shopping.' }, ],});A step with no anchor renders as a centered modal — useful for an opening or
closing card.
Mark your anchors#
The most churn-resistant strategy is an explicit attribute, because it is the only one that is meant to be a hook:
<button data-scalebun-anchor="checkout">Checkout</button>Each framework has an idiomatic wrapper for this — ScaleBunAnchor in React,
v-scalebun-anchor in Vue, use:scalebunAnchor in Svelte, scalebunAnchor in
Angular. See Integrations.
Call coach.reportAnchors() on a page and its anchor keys are reported to the
dashboard, so the tour composer offers a list instead of a free-text box.
Anchor resolution#
An AnchorSpec takes a prioritized list of strategies. Resolvers run in
order and the first stable hit wins, so a tour survives the change that would
have broken a single CSS selector.
| Strategy | Example |
|---|---|
data-attr | { kind: 'data-attr', value: 'checkout' } — most stable |
selector | { kind: 'selector', value: '#checkout .cta' } |
text | { kind: 'text', value: 'Checkout', scope: 'button' } |
aria | { kind: 'aria', role: 'button', name: 'Checkout' } |
xpath | { kind: 'xpath', value: '//button[1]' } |
nth | { kind: 'nth', selector: '.row', index: 2 } |
Open shadow roots are traversed by default (shadowPiercing), and an element
must be at least 50% visible to count (minVisibleRatio).
When a later strategy resolves after an earlier one failed, the SDK emits
$coachmark_anchor_healed — that event is how you find a selector that has
quietly rotted before it fails outright.
Never dead-end#
If an anchor cannot be resolved at all, the step's fallback decides what
happens. It defaults to modal, which shows the step centered rather than
abandoning the tour.
fallback | Behaviour |
|---|---|
modal (default) | Show the step as a centered modal, flagged as degraded. |
skip | Move to the next step. |
end | End the tour. |
Failures are reported with a reason — not-found, not-visible,
not-clickable, timeout, covered, shadow-closed,
iframe-cross-origin — so anchor health is measurable rather than anecdotal.
Advancing#
advance: 'next' // a Next button or coach.next(). Default.advance: 'anchor-click' // the user clicks the anchored elementadvance: 'anchor-gone' // the anchor disappears — they completed the actionadvance: { event: 'saved' } // your app calls coach.notify('saved')advance: { timer: 4000 } // auto-advanceanchor-click is usually the right choice for a tour that should follow the
real product flow rather than talk over it.
Branching#
next takes either a step id or a list of branches; the first whose when
passes wins. skipIf drops a step entirely when its predicate passes.
{ id: 'plan', next: [ { to: 'enterprise-setup', when: { prop: 'plan', op: 'eq', value: 'enterprise' } }, { to: 'self-serve-setup' }, ],}Predicates are serializable — { prop, op, value }, { flag, is }, { all },
{ any }, { not } — which is what lets the dashboard author them too.
Control and observe#
coach.next(); coach.back(); coach.skip(); coach.end();coach.notify('saved');coach.current(); // { tourId, index, status }
const off = coach.on('$coachmark_step_completed', (e) => console.log(e));Twelve $coachmark_* events are emitted across the tour lifecycle, and every
step is stamped with the current replay coordinate — so you can watch the frame
where someone abandoned a step rather than guessing why.
Previewing a draft#
startById accepts a preview token from the dashboard's Preview action, which
runs an unpublished tour:
coach.startById('onboarding', { preview: token, resume: false });Next#
In-app messaging — the other anchored surfaces.
Reserved events — the
$coachmark_*vocabulary.