Declare your anchors
01One registry. Rename a key and every flow that used it stops compiling.
export const anchors = defineAnchors({ questions: { save: "questions.save" }, });
Every other tour tool targets a CSS selector, so a rename breaks it silently and a customer finds out first. cairnkit puts tours in your repo as typed data and fails CI the moment one points at UI that no longer exists.
npm i @cairnkit/core @cairnkit/react @cairnkit/uiYour stack
One client component holds the provider. Anchors still work in server components, because anchor() returns plain props.
"use client"; import { CairnProvider } from "@cairnkit/react"; import { useAppRouterAdapter } from "@cairnkit/next"; import { CairnOverlay } from "@cairnkit/ui"; import { flows } from "@/walkthrough/flows"; export function Providers({ children }) { return ( <CairnProvider flows={flows} router={useAppRouterAdapter()}> {children} <CairnOverlay /> </CairnProvider> ); }
The whole idea
Someone deletes a button. Every other tool keeps pointing at nothing until a user reports a spotlight over blank space. This runs in about a second, in CI.
$ cairnkit check ✗ cairnkit check failed • 1 anchor(s) are registered but never applied to an element [anchors-applied] - questions.save (breaks "create-questions") src/walkthrough/flows.ts:35 Spread {...anchor(...)} on the element, or remove the anchor and the step pointing at it.
Comparison
Not a speed benchmark — cairnkit is not faster than driver.js. This is the axis that actually costs you: what each tool does the day someone edits a component.
| driver.js | Shepherd | Pendo | cairnkit | |
|---|---|---|---|---|
| Targets elements by | CSS selector | CSS selector | Visual picker | Typed anchor |
| You rename a class | Breaks silently | Breaks silently | Breaks silently | Won't compile |
| You delete the element | Breaks silently | Breaks silently | Breaks silently | Fails CI |
| Element stops rendering | Breaks silently | Breaks silently | Breaks silently | Fails the audit |
| Step inside a modal | Often breaks | Often breaks | Works | Works |
How it works
No visual editor, no separate dashboard to keep in sync. Tours live next to the code they describe and move with it.
One registry. Rename a key and every flow that used it stops compiling.
export const anchors = defineAnchors({ questions: { save: "questions.save" }, });
A single spread. Your components import nothing else from cairnkit.
<button {...anchor(anchors.questions.save)}>
Save
</button>Steps are data, not components. Reordering is an array edit.
defineFlow({ id: "create-questions", version: 1, entryRoute: "/questions", steps: [{ anchor: anchors.questions.save, title: "Save it", advanceOn: { type: "click" } }], });
Now a deleted button fails the build instead of a customer's onboarding.
"scripts": { "lint": "eslint . && cairnkit check" }
Try it
A real tour against a real UI. Change the placement, padding and advance rule, watch the spotlight move, then copy the flow that produced it. Nothing to install.
Open the playgroundReal users
They click ahead, wander into a different flow, or open a modal you did not plan for. Most tours die at that point. cairnkit expects it.
They didClicked the button before the guide got there
cairnkit doesCatches up, forward only
resumeAtThey didTook a different route to the same goal
cairnkit doesHands over to the guide that covers it
handoffRoutesThey didWandered somewhere no guide covers
cairnkit doesSleeps and keeps their place
pauseRoutesThey didOpened a modal you did not plan for
cairnkit doesPortals into the dialog, survives focus traps
automaticThey didLeft a modal with the next target behind it
cairnkit doesRuns your onExit, waits for the close, then measures
onExitPackages
Take the engine alone and drive it with your own components, or take the overlay too. Headless lands at 5.4 kb — about what driver.js costs, for rather more.
@cairnkit/coreEngine. Zero dependencies.2.3 kb@cairnkit/reactHeadless hooks and provider.2.9 kb@cairnkit/nextoptionalRouter adapters.0.2 kb@cairnkit/uioptionalPrebuilt overlay, JS + CSS.5.5 kb@cairnkit/cloudoptionalReports events to cloud.1.7 kbcairnkit cloud
cairnkit check catches a tour pointing at a button that no longer exists. It has nothing to say about the tour that works perfectly and still loses two thirds of people at step three — that one is not a bug in your repo, and no compiler will ever find it.
import { sendToCloud } from "@cairnkit/cloud"; <CairnProvider flows={flows} onEvent={sendToCloud({ key })}>
You already pass onEvent. Pointing it at cloud is one import and 1.7 kb, and what comes back is completion rate per flow, the step people quit on, and a Slack message the moment an anchor goes missing in production. Point it at PostHog instead and the library does not care — this is an option, not the plan.
MIT licensed, 2.3 kb of engine, and a CI check that tells you before your users do. Five minutes to a working tour; an afternoon to one that cannot silently break.
$ cairnkit check ✓ cairnkit check · 12 anchors, all applied, no route conflicts $ echo $? 0