useTour
Usage
Use the auto-imported useTour composable to drive a guided tour with a single Popover whose anchor moves between steps. The composable owns the step state and resolves each step's target into a reference you bind to <PPopover>, while you keep full control over the content and navigation.
Dashboard
Overview of your workspace.
Profile
Manage your account.
Settings
Configure your preferences.
<script setup lang="ts">
const dashboard = useTemplateRef('dashboard');
const profile = useTemplateRef('profile');
const settings = useTemplateRef('settings');
const tour = useTour([
{
target: () => dashboard.value,
title: 'Welcome aboard',
body: 'The popover re-anchors to each target as you step through the tour.',
},
{
target: () => profile.value,
title: 'Make it yours',
body: 'You own the content and the buttons — no extra theme or locale to maintain.',
side: 'right' as const,
},
{
target: () => settings.value,
title: 'You\'re all set',
body: 'Press Finish to close the tour.',
},
]);
</script>
<template>
<div class="w-full space-y-4">
<div class="flex justify-end">
<PButton
icon="i-lucide-wand-sparkles"
@click="tour.start()"
>
Start tour
</PButton>
</div>
<div class="gap-4 grid grid-cols-1 sm:grid-cols-3">
<div
ref="dashboard"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Dashboard
</p>
<p class="text-sm color-text-muted">
Overview of your workspace.
</p>
</div>
<div
ref="profile"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Profile
</p>
<p class="text-sm color-text-muted">
Manage your account.
</p>
</div>
<div
ref="settings"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Settings
</p>
<p class="text-sm color-text-muted">
Configure your preferences.
</p>
</div>
</div>
<PPopover
:open="tour.open.value"
:reference="tour.reference.value"
:content="{ side: tour.current.value?.side, sideOffset: 8 }"
:dismissible="false"
arrow
>
<template #content>
<div class="p-4 max-w-xs space-y-2">
<div class="flex gap-4 items-center justify-between">
<p class="color-text-highlighted font-semibold">
{{ tour.current.value?.title }}
</p>
<span class="text-xs color-text-muted tabular-nums">
{{ tour.index.value + 1 }} / {{ tour.total.value }}
</span>
</div>
<p class="text-sm color-text-muted">
{{ tour.current.value?.body }}
</p>
<div class="pt-2 flex items-center justify-between">
<PButton
color="neutral"
variant="outline"
size="sm"
:disabled="!tour.hasPrev.value"
@click="tour.prev()"
>
Back
</PButton>
<PButton
size="sm"
@click="tour.next()"
>
{{ tour.hasNext.value ? 'Next' : 'Finish' }}
</PButton>
</div>
</div>
</template>
</PPopover>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const dashboard = useTemplateRef('dashboard');
const profile = useTemplateRef('profile');
const settings = useTemplateRef('settings');
const tour = useTour([
{
target: () => dashboard.value,
title: 'Welcome aboard',
body: 'The popover re-anchors to each target as you step through the tour.',
},
{
target: () => profile.value,
title: 'Make it yours',
body: 'You own the content and the buttons — no extra theme or locale to maintain.',
side: 'right' as const,
},
{
target: () => settings.value,
title: 'You\'re all set',
body: 'Press Finish to close the tour.',
},
]);
</script>
<template>
<div class="w-full space-y-4">
<div class="flex justify-end">
<PButton
icon="i-lucide-wand-sparkles"
@click="tour.start()"
>
Start tour
</PButton>
</div>
<div class="gap-4 grid grid-cols-1 sm:grid-cols-3">
<div
ref="dashboard"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Dashboard
</p>
<p class="text-sm color-text-muted">
Overview of your workspace.
</p>
</div>
<div
ref="profile"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Profile
</p>
<p class="text-sm color-text-muted">
Manage your account.
</p>
</div>
<div
ref="settings"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Settings
</p>
<p class="text-sm color-text-muted">
Configure your preferences.
</p>
</div>
</div>
<PPopover
:open="tour.open.value"
:reference="tour.reference.value"
:content="{ side: tour.current.value?.side, sideOffset: 8 }"
:dismissible="false"
arrow
>
<template #content>
<div class="p-4 max-w-xs space-y-2">
<div class="flex gap-4 items-center justify-between">
<p class="color-text-highlighted font-semibold">
{{ tour.current.value?.title }}
</p>
<span class="text-xs color-text-muted tabular-nums">
{{ tour.index.value + 1 }} / {{ tour.total.value }}
</span>
</div>
<p class="text-sm color-text-muted">
{{ tour.current.value?.body }}
</p>
<div class="pt-2 flex items-center justify-between">
<PButton
color="neutral"
variant="outline"
size="sm"
:disabled="!tour.hasPrev.value"
@click="tour.prev()"
>
Back
</PButton>
<PButton
size="sm"
@click="tour.next()"
>
{{ tour.hasNext.value ? 'Next' : 'Finish' }}
</PButton>
</div>
</div>
</template>
</PPopover>
</div>
</template>
Each step requires a target that the popover anchors to. It accepts a bare id (a plain word is always treated as an id, never as a tag selector) or any CSS selector, an element, a virtual element (anything with getBoundingClientRect), or a ref/getter returning one of those. Pass null to anchor the step to the center of the viewport. Any other field on a step (title, body, side, …) is passed through untouched and available via current.
<script setup lang="ts">
const card = useTemplateRef('card');
const tour = useTour([
{ target: '#cta', title: 'Get started' },
{ target: () => card.value, title: 'Profile', side: 'right' },
{ target: null, title: 'All set' }
]);
</script>
<template>
<PButton @click="tour.start()">
Start tour
</PButton>
<PPopover
:open="tour.open.value"
:reference="tour.reference.value"
:dismissible="false"
>
<template #content>
<!-- your content + buttons -->
<PButton
:disabled="!tour.hasPrev.value"
@click="tour.prev()"
>
Back
</PButton>
<PButton @click="tour.next()">
{{ tour.hasNext.value ? 'Next' : 'Finish' }}
</PButton>
</template>
</PPopover>
</template>
- Built on the Popover's reactive
referenceprop, so the popover smoothly repositions when the active step changes. - The active target is scrolled into view automatically when a step becomes active.
- Since you render the content yourself, there is no extra theme or locale to maintain.
API
useTour(steps, options?)
Parameters
ref, or a getter for reactive steps.'id' — a plain word is always treated as an id, never as a tag selector) or any CSS selector ('#id', '.class', '[data-tour="step"]', …), an element, a virtual element, or a ref/getter returning one. Use null to center the step in the viewport.title, body, side, …) are passed through and available via current.Return
undefined when there are no steps.<PPopover :reference>.loop option.Examples
Spotlight
This example demonstrates how to dim the page around the current target by combining reference with VueUse's useElementBounding. The target stays clickable while the rest of the page is blocked, and a step without a target dims the whole page. Setting it up takes three steps:
- Copy the overlay component below into your app. It measures the target with
useElementBoundingand draws aclip-pathbackdrop with a cutout and a ring around it. - Render
<UseTourSpotlight :tour="tour" />next to your<PPopover>. Keep the measuring inside the overlay: the box updates on every scroll frame, so reading it in the parent template would re-render the whole parent while scrolling. - Give the popover
:ui="{ content: 'z-50' }"so it stays above the backdrop (z-40), and asideOffsetof16so it clears the cutout padding.
<script setup lang="ts">
import type { UseTourReturn } from 'pohon-ui/composables';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const props = withDefaults(defineProps<{
tour: UseTourReturn;
padding?: number;
}>(), {
padding: 8,
});
// Only real elements can be measured: a centered step anchors to a virtual element,
// which `ResizeObserver` and `MutationObserver` reject.
const target = computed(() => {
const el = props.tour.reference.value;
return el && el instanceof Element ? el as HTMLElement : null;
});
const { top, left, width: targetWidth, height: targetHeight } = useElementBounding(target);
const { width, height } = useWindowSize();
// The target's own corner radius, so the theme radius carries over. Read once per step, not per frame.
const targetRadius = computed(() => target.value ? Number.parseFloat(getComputedStyle(target.value).borderTopLeftRadius) || 0 : 0);
// The box is physical even in RTL, hence `left-0` + `translate` on the ring below.
const hole = computed(() => {
// A target removed from the DOM would measure as an empty box at the origin.
if (!target.value?.isConnected) {
return null;
}
const p = props.padding;
const w = targetWidth.value + p * 2;
const h = targetHeight.value + p * 2;
// Concentric corners: a rounded target gets its radius plus the padding, a square one stays square.
const r = targetRadius.value > 0 ? targetRadius.value + p : 0;
return { x: left.value - p, y: top.value - p, w, h, r: Math.min(r, w / 2, h / 2) };
});
// Full-viewport backdrop minus a rounded cutout. `clip-path` also clips hit testing,
// so the target stays clickable while the rest of the page is blocked.
const clipPath = computed(() => {
if (!hole.value) {
return undefined;
}
const { x, y, w, h, r } = hole.value;
return `path(evenodd, "M0 0H${width.value}V${height.value}H0Z M${x + r} ${y}h${w - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${h - 2 * r}a${r} ${r} 0 0 1 ${-r} ${r}h${2 * r - w}a${r} ${r} 0 0 1 ${-r} ${-r}v${2 * r - h}a${r} ${r} 0 0 1 ${r} ${-r}Z")`;
});
</script>
<template>
<Teleport to="body">
<template v-if="tour.open.value">
<div
aria-hidden="true"
class="bg-black/50 inset-0 fixed z-40"
:style="{ clipPath }"
/>
<div
v-if="hole"
aria-hidden="true"
class="pointer-events-none ring-2 ring-primary left-0 top-0 fixed z-40"
:style="{ width: `${hole.w}px`, height: `${hole.h}px`, borderRadius: `${hole.r}px`, transform: `translate(${hole.x}px, ${hole.y}px)` }"
/>
</template>
</Teleport>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import type { UseTourReturn } from 'pohon-ui/composables';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const props = withDefaults(defineProps<{
tour: UseTourReturn;
padding?: number;
}>(), {
padding: 8,
});
// Only real elements can be measured: a centered step anchors to a virtual element,
// which `ResizeObserver` and `MutationObserver` reject.
const target = computed(() => {
const el = props.tour.reference.value;
return el && el instanceof Element ? el as HTMLElement : null;
});
const { top, left, width: targetWidth, height: targetHeight } = useElementBounding(target);
const { width, height } = useWindowSize();
// The target's own corner radius, so the theme radius carries over. Read once per step, not per frame.
const targetRadius = computed(() => target.value ? Number.parseFloat(getComputedStyle(target.value).borderTopLeftRadius) || 0 : 0);
// The box is physical even in RTL, hence `left-0` + `translate` on the ring below.
const hole = computed(() => {
// A target removed from the DOM would measure as an empty box at the origin.
if (!target.value?.isConnected) {
return null;
}
const p = props.padding;
const w = targetWidth.value + p * 2;
const h = targetHeight.value + p * 2;
// Concentric corners: a rounded target gets its radius plus the padding, a square one stays square.
const r = targetRadius.value > 0 ? targetRadius.value + p : 0;
return { x: left.value - p, y: top.value - p, w, h, r: Math.min(r, w / 2, h / 2) };
});
// Full-viewport backdrop minus a rounded cutout. `clip-path` also clips hit testing,
// so the target stays clickable while the rest of the page is blocked.
const clipPath = computed(() => {
if (!hole.value) {
return undefined;
}
const { x, y, w, h, r } = hole.value;
return `path(evenodd, "M0 0H${width.value}V${height.value}H0Z M${x + r} ${y}h${w - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${h - 2 * r}a${r} ${r} 0 0 1 ${-r} ${r}h${2 * r - w}a${r} ${r} 0 0 1 ${-r} ${-r}v${2 * r - h}a${r} ${r} 0 0 1 ${r} ${-r}Z")`;
});
</script>
<template>
<Teleport to="body">
<template v-if="tour.open.value">
<div
aria-hidden="true"
class="bg-black/50 inset-0 fixed z-40"
:style="{ clipPath }"
/>
<div
v-if="hole"
aria-hidden="true"
class="pointer-events-none ring-2 ring-primary left-0 top-0 fixed z-40"
:style="{ width: `${hole.w}px`, height: `${hole.h}px`, borderRadius: `${hole.r}px`, transform: `translate(${hole.x}px, ${hole.y}px)` }"
/>
</template>
</Teleport>
</template>
Dashboard
Overview of your workspace.
Profile
Manage your account.
Settings
Configure your preferences.
<script setup lang="ts">
import UseTourSpotlight from './UseTourSpotlight.vue';
const dashboard = useTemplateRef('dashboard');
const profile = useTemplateRef('profile');
const settings = useTemplateRef('settings');
const tour = useTour([
{
target: () => dashboard.value,
title: 'Welcome aboard',
body: 'The popover re-anchors to each target as you step through the tour.',
},
{
target: () => profile.value,
title: 'Make it yours',
body: 'You own the content and the buttons, no extra theme or locale to maintain.',
side: 'right' as const,
},
{
target: () => settings.value,
title: 'You\'re all set',
body: 'Press Finish to close the tour.',
},
{
target: null,
title: 'Centered step',
body: 'A step without a target dims the whole page.',
},
]);
</script>
<template>
<div class="w-full space-y-4">
<div class="flex justify-end">
<PButton
icon="i-lucide-wand-sparkles"
@click="tour.start()"
>
Start tour
</PButton>
</div>
<div class="gap-4 grid grid-cols-1 sm:grid-cols-3">
<div
ref="dashboard"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Dashboard
</p>
<p class="text-sm color-text-muted">
Overview of your workspace.
</p>
</div>
<div
ref="profile"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Profile
</p>
<p class="text-sm color-text-muted">
Manage your account.
</p>
</div>
<div
ref="settings"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Settings
</p>
<p class="text-sm color-text-muted">
Configure your preferences.
</p>
</div>
</div>
<UseTourSpotlight :tour="tour" />
<PPopover
:open="tour.open.value"
:reference="tour.reference.value"
:content="{ side: tour.current.value?.side, sideOffset: 16 }"
:dismissible="false"
:ui="{ content: 'z-50' }"
arrow
>
<template #content>
<div class="p-4 max-w-xs space-y-2">
<div class="flex gap-4 items-center justify-between">
<p class="color-text-highlighted font-semibold">
{{ tour.current.value?.title }}
</p>
<span class="text-xs color-text-muted tabular-nums">
{{ tour.index.value + 1 }} / {{ tour.total.value }}
</span>
</div>
<p class="text-sm color-text-muted">
{{ tour.current.value?.body }}
</p>
<div class="pt-2 flex items-center justify-between">
<PButton
color="neutral"
variant="outline"
size="sm"
:disabled="!tour.hasPrev.value"
@click="tour.prev()"
>
Back
</PButton>
<PButton
size="sm"
@click="tour.next()"
>
{{ tour.hasNext.value ? 'Next' : 'Finish' }}
</PButton>
</div>
</div>
</template>
</PPopover>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import UseTourSpotlight from './UseTourSpotlight.vue';
const dashboard = useTemplateRef('dashboard');
const profile = useTemplateRef('profile');
const settings = useTemplateRef('settings');
const tour = useTour([
{
target: () => dashboard.value,
title: 'Welcome aboard',
body: 'The popover re-anchors to each target as you step through the tour.',
},
{
target: () => profile.value,
title: 'Make it yours',
body: 'You own the content and the buttons, no extra theme or locale to maintain.',
side: 'right' as const,
},
{
target: () => settings.value,
title: 'You\'re all set',
body: 'Press Finish to close the tour.',
},
{
target: null,
title: 'Centered step',
body: 'A step without a target dims the whole page.',
},
]);
</script>
<template>
<div class="w-full space-y-4">
<div class="flex justify-end">
<PButton
icon="i-lucide-wand-sparkles"
@click="tour.start()"
>
Start tour
</PButton>
</div>
<div class="gap-4 grid grid-cols-1 sm:grid-cols-3">
<div
ref="dashboard"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Dashboard
</p>
<p class="text-sm color-text-muted">
Overview of your workspace.
</p>
</div>
<div
ref="profile"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Profile
</p>
<p class="text-sm color-text-muted">
Manage your account.
</p>
</div>
<div
ref="settings"
class="p-4 border border-border rounded-lg bg-background-elevated/50"
>
<p class="color-text-highlighted font-medium">
Settings
</p>
<p class="text-sm color-text-muted">
Configure your preferences.
</p>
</div>
</div>
<UseTourSpotlight :tour="tour" />
<PPopover
:open="tour.open.value"
:reference="tour.reference.value"
:content="{ side: tour.current.value?.side, sideOffset: 16 }"
:dismissible="false"
:ui="{ content: 'z-50' }"
arrow
>
<template #content>
<div class="p-4 max-w-xs space-y-2">
<div class="flex gap-4 items-center justify-between">
<p class="color-text-highlighted font-semibold">
{{ tour.current.value?.title }}
</p>
<span class="text-xs color-text-muted tabular-nums">
{{ tour.index.value + 1 }} / {{ tour.total.value }}
</span>
</div>
<p class="text-sm color-text-muted">
{{ tour.current.value?.body }}
</p>
<div class="pt-2 flex items-center justify-between">
<PButton
color="neutral"
variant="outline"
size="sm"
:disabled="!tour.hasPrev.value"
@click="tour.prev()"
>
Back
</PButton>
<PButton
size="sm"
@click="tour.next()"
>
{{ tour.hasNext.value ? 'Next' : 'Finish' }}
</PButton>
</div>
</div>
</template>
</PPopover>
</div>
</template>