useTour

A composable to build guided tours by re-anchoring a single Popover across steps.

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 reference prop, 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

steps
MaybeRefOrGetter<TourStep[]> required
The list of tour steps. Can be a static array, a ref, or a getter for reactive steps.
options
UseTourOptions
Configuration options for the tour.

Return

open
Ref<boolean>
Whether the tour is currently open.
index
Ref<number>
The current step index, clamped to the steps range.
current
ComputedRef<TourStep | undefined>
The current step object, or undefined when there are no steps.
reference
ComputedRef<ReferenceElement | undefined>
The resolved anchor for the current step, to pass to <PPopover :reference>.
total
ComputedRef<number>
The total number of steps.
hasNext
ComputedRef<boolean>
Whether a next step exists.
hasPrev
ComputedRef<boolean>
Whether a previous step exists.
start
(index?: number) => void
Open the tour, optionally at a given index.
next
() => void
Go to the next step. Loops or finishes at the end depending on the loop option.
prev
() => void
Go to the previous step.
goTo
(index: number) => void
Jump to a specific step and open the tour.
finish
() => void
Close the tour.

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:

  1. Copy the overlay component below into your app. It measures the target with useElementBounding and draws a clip-path backdrop with a cutout and a ring around it.
  2. 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.
  3. Give the popover :ui="{ content: 'z-50' }" so it stays above the backdrop (z-40), and a sideOffset of 16 so it clears the cutout padding.
UseTourSpotlight.vue
<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>
UseTourSpotlight.vue
<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>