@tour-kit/vue
Headless product tours for Vue 3, built on the framework-agnostic Tour Kit engine — `provideTourKit`, `useTour`, the spotlight and focus-trap composables, and a `vue-router` adapter. No React anywhere in the dependency or type chain.
@tour-kit/vue is a binding over @tour-kit/core/engine,
the React-free core. It is headless in the strict sense: no card, no overlay, no
positioning library. You render the UI, the engine owns the state machine.
No React, anywhere. The package imports the /engine subpath only, never
@tour-kit/core bare, so neither react nor react-dom appears in the runtime
or the .d.ts chain. A per-package test enforces it on both the built files and
the source, with a positive control so a broken scan fails loudly.
Install
pnpm add @tour-kit/vueVue 3 is a peer dependency. vue-router is optional — you only need it if your
tour spans routes.
Quick start
There are two ways in, and they are the same code. provideTourKit is the
composable; <TourProvider> is a thin component wrapper over it that takes the
same options as props.
<!-- App.vue -->
<script setup lang="ts">
import { createVueRouterAdapter, provideTourKit } from '@tour-kit/vue'
import { useRouter } from 'vue-router'
import { tours } from './tours'
const kit = provideTourKit({
tours,
router: createVueRouterAdapter(useRouter()),
routePersistence: { enabled: true, flowSession: { storage: 'sessionStorage' } },
})
kit.start('onboarding')
</script>
<template>
<RouterView />
</template><!-- App.vue -->
<script setup lang="ts">
import { TourProvider } from '@tour-kit/vue'
import { tours } from './tours'
</script>
<template>
<TourProvider :tours="tours">
<RouterView />
</TourProvider>
</template>Anywhere below the provider, useTour() returns the same kit:
<script setup lang="ts">
import { useTour } from '@tour-kit/vue'
const tour = useTour()
</script>
<template>
<div v-if="tour.state.value.isActive">
<p>{{ tour.state.value.currentStep?.content }}</p>
<button @click="tour.prev()">Back</button>
<button @click="tour.next()">Next</button>
</div>
</template>The kit
useTour() throws outside a provider. What it returns is one reactive snapshot
plus the verbs.
| Member | Type | Notes |
|---|---|---|
state | Readonly<ShallowRef<TourCallbackContext>> | Read state.value. New identity per transition |
start, next, prev, goTo, skip, complete, stop | verbs | Stable identity — safe to pass as props |
goToStep, startTour, triggerBranchAction | Promise-returning verbs | Step IDs are narrowed to your step union |
reset, setData, setDontShowAgain | verbs | |
setOptions, setTours | (patch) => void | Update live options or swap the tour set |
state is a shallowRef, not a ref, deliberately. The engine returns the same
object reference until the next transition, so identity comparison is the whole
point — a deep ref would proxy the stepVisitCount Map and the tour object,
and every downstream Object.is would break against the proxy.
Escape maps to skip(), not stop(), and a skip persists. A user who
presses Escape has dismissed the tour, not paused it.
Routing
createVueRouterAdapter wraps a vue-router instance. It needs only three
things from it, so anything with the same shape works in a test:
import { createVueRouterAdapter } from '@tour-kit/vue'
import { useRouter } from 'vue-router'
provideTourKit({
tours,
router: createVueRouterAdapter(useRouter()),
autoNavigate: true,
})onRouteChange fires immediately with the current route, matching every other
adapter — the engine's route-restore path depends on it.
Behaviours
The binding ships the two DOM behaviours that are genuinely hard to get right.
Positioning is not among them: use @floating-ui/dom directly, as
examples/vue-app does.
useSpotlight()
A bridge over core's spotlight state machine. Returns computed refs plus three methods, and tears down its tracker on scope dispose.
<script setup lang="ts">
import { resolveTarget, useSpotlight, useTour } from '@tour-kit/vue'
import { watch } from 'vue'
const tour = useTour()
const { isVisible, overlayStyle, cutoutStyle, show, hide } = useSpotlight()
watch(
() => tour.state.value.currentStep,
(step) => {
const el = step?.target ? resolveTarget(step.target) : null
el ? show(el) : hide()
}
)
</script>
<template>
<div v-if="isVisible" :style="overlayStyle" />
<div v-if="isVisible" :style="cutoutStyle" />
</template>useFocusTrap()
<script setup lang="ts">
import { useFocusTrap, useTour } from '@tour-kit/vue'
const tour = useTour()
const { containerRef, activate, deactivate } = useFocusTrap(() => tour.state.value.isActive)
</script>
<template>
<div ref="containerRef" role="dialog" aria-modal="true">…</div>
</template>The card owns deactivate(), not the binding. The enabled gate only
decides whether activate() does anything. Restoring focus when the tour ends
is your component's cleanup — the same contract as the React hook.
Licensing
@tour-kit/vue ships under BUSL-1.1. Development, evaluation, testing, CI and
any non-production environment are free and need no key. Production needs one.
Without a key the binding still works in full and layers a small badge in the
corner on non-development hosts. See Licensing for the full
setup.
<TourProvider
:tours="tours"
:license="{ licenseKey: import.meta.env.VITE_TOUR_KIT_LICENSE_KEY }"
>
<RouterView />
</TourProvider>The key is reactive here. A key fetched from an API or hydrated out of a
store after mount still takes the badge down — the binding watches licenseKey
and restarts the gate when it changes. This differs from
@tour-kit/svelte, where options are read once. On a
development host the key is never sent anywhere, so local work never consumes an
activation slot.
SSR and Nuxt
The binding is server-safe by construction, and the rule behind that is worth knowing before you reach for a lifecycle hook.
Nothing constructs an engine during setup(). The engine is built lazily on
the first verb, and every kit member except state is that construction in
disguise — setOptions and setTours included. Only reading state is free.
This is why the provider's watchers are non-immediate. A watchEffect, or a
watch(..., { immediate: true }), runs its callback synchronously inside
setup(), which under Nuxt would mean registry writes, four storage adapters and
a BroadcastChannel on the server. Watchers do not run during SSR at all, so
a non-immediate watch is inert there by construction.
A child's onMounted fires before the provider's. So
onMounted(() => tour.start()) in a page component lands before the provider has
booted — and works anyway, because the handle constructs on first use rather than
on a schedule. Do not "fix" this by constructing earlier.
Everything from /engine, too
The package barrel re-exports all of @tour-kit/core/engine, so types and
helpers come from one import:
import type { Tour, TourStep } from '@tour-kit/vue'That is deliberate rather than lazy. /engine is already the curated
React-free surface; a hand-picked subset would need its own alignment test and
would still be wrong the day /engine grows. See
the engine guide for what lives there.
Ship onboarding, not config.
npm i @tour-kit/core is free while you build. Every package works unlicensed in development, a one-time licence from $9.99 removes the production watermark when you ship.
Free in development, no signup, no credit card. Pay once, only when you ship.
HintTooltipHeadless
HeadlessHintTooltip: unstyled floating tooltip with calculated position and collision avoidance via render props
@tour-kit/svelte
Headless product tours for Svelte 5, built on the framework-agnostic Tour Kit engine — `provideTourKit`, `getTour`, a spotlight bridge, a `focusTrap` action, and a SvelteKit router adapter. No React anywhere in the dependency or type chain.