Tour
Vue overlayMulti-step guided overlay walkthrough. Highlights a target element with a dim mask cutout and shows a card next to it. Steps support targets by selector, ref, or function; centered (no-target) steps work as modal-style intros.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tour.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tour.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tour.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/tour.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/tour
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
open | boolean | false | optional |
current | number | 0 | optional |
steps | TourStep[] | — | required |
mask | boolean | true | optional |
type | 'default''primary' | 'default' | optional |
zIndex | number | 1000 | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TargetRect interface TargetRect {
x: number
y: number
width: number
height: number
} TourStep interface TourStep {
target?: TourTarget
title: string
description?: string
cover?: string
mask?: boolean
nextButtonText?: string
prevButtonText?: string
} Dependencies
Files (5)
-
app/components/ui/tour/Tour.vue 3 kB
<script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue' import TourMask from './TourMask.vue' import TourCard from './TourCard.vue' import { useTourTarget } from './use-tour-target' import type { TourStep } from '.' const props = withDefaults( defineProps<{ open?: boolean current?: number steps: TourStep[] mask?: boolean type?: 'default' | 'primary' zIndex?: number }>(), { open: false, current: 0, mask: true, type: 'default', zIndex: 1000, }, ) const emits = defineEmits<{ (e: 'update:open', v: boolean): void (e: 'update:current', v: number): void (e: 'change', v: number): void (e: 'finish'): void (e: 'close'): void }>() const stepIndex = ref(props.current) watch( () => props.current, (v) => (stepIndex.value = v), ) const currentStep = computed<TourStep | null>(() => props.steps[stepIndex.value] ?? null) const targetRef = computed(() => currentStep.value?.target) const { rect, attach, measure } = useTourTarget(targetRef) watch( [() => props.open, stepIndex], async ([open]) => { if (!open) return await nextTick() attach() const t = currentStep.value?.target if (t) { const el = typeof t === 'string' ? (document.querySelector(t) as HTMLElement | null) : typeof t === 'function' ? t() : t el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) setTimeout(measure, 320) } }, { immediate: true }, ) function setStep(i: number) { stepIndex.value = i emits('update:current', i) emits('change', i) } function next() { if (stepIndex.value < props.steps.length - 1) setStep(stepIndex.value + 1) } function prev() { if (stepIndex.value > 0) setStep(stepIndex.value - 1) } function finish() { emits('finish') emits('update:open', false) } function skip() { emits('close') emits('update:open', false) } function onKeydown(e: KeyboardEvent) { if (!props.open) return if (e.key === 'Escape') { e.preventDefault() skip() } } watch( () => props.open, (v) => { if (typeof document === 'undefined') return if (v) document.addEventListener('keydown', onKeydown) else document.removeEventListener('keydown', onKeydown) }, ) const showMask = computed(() => { const stepMask = currentStep.value?.mask if (stepMask !== undefined) return stepMask return props.mask }) </script> <template> <Teleport to="body"> <template v-if="open && currentStep"> <TourMask v-if="showMask" :rect="rect" :z-index="zIndex" /> <TourCard :title="currentStep.title" :description="currentStep.description" :cover="currentStep.cover" :rect="rect" :total="steps.length" :current="stepIndex" :prev-text="currentStep.prevButtonText" :next-text="currentStep.nextButtonText" :type="type" :z-index="zIndex" @prev="prev" @next="next" @finish="finish" @skip="skip" /> </template> </Teleport> </template> -
app/components/ui/tour/TourMask.vue 1.3 kB
<script setup lang="ts"> import { computed } from 'vue' import type { TargetRect } from './use-tour-target' const props = defineProps<{ rect: TargetRect | null zIndex: number opacity?: number padding?: number radius?: number }>() const opacity = computed(() => props.opacity ?? 0.5) const padding = computed(() => props.padding ?? 4) const radius = computed(() => props.radius ?? 6) const cutout = computed(() => { const r = props.rect if (!r) return null return { x: r.x - padding.value, y: r.y - padding.value, w: r.width + padding.value * 2, h: r.height + padding.value * 2, } }) </script> <template> <svg class="pointer-events-none fixed inset-0" :style="{ zIndex }" width="100%" height="100%" aria-hidden="true"> <defs> <mask id="uipkge-tour-mask"> <rect width="100%" height="100%" fill="white" /> <rect v-if="cutout" :x="cutout.x" :y="cutout.y" :width="cutout.w" :height="cutout.h" :rx="radius" fill="black" /> </mask> </defs> <rect width="100%" height="100%" :fill="`rgba(0, 0, 0, ${opacity})`" mask="url(#uipkge-tour-mask)" class="pointer-events-auto" :style="{ transition: 'all 200ms ease' }" /> </svg> </template> -
app/components/ui/tour/TourCard.vue 3.3 kB
<script setup lang="ts"> import { computed } from 'vue' import { X } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { TargetRect } from './use-tour-target' const props = withDefaults( defineProps<{ title: string description?: string cover?: string rect: TargetRect | null total: number current: number prevText?: string nextText?: string type?: 'default' | 'primary' zIndex: number }>(), { prevText: 'Previous', nextText: 'Next', type: 'default', }, ) defineEmits<{ (e: 'prev'): void (e: 'next'): void (e: 'finish'): void (e: 'skip'): void }>() const isLast = computed(() => props.current === props.total - 1) const isFirst = computed(() => props.current === 0) const cardStyle = computed(() => { const cardWidth = 320 const margin = 12 if (!props.rect) { return { position: 'fixed' as const, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: `${cardWidth}px`, zIndex: props.zIndex + 1, } } const { x, y, width, height } = props.rect const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768 const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024 const placeBelow = y + height + margin + 200 < viewportH const top = placeBelow ? y + height + margin : Math.max(8, y - margin - 200) let left = x if (left + cardWidth > viewportW - 8) { left = viewportW - cardWidth - 8 } if (left < 8) left = 8 return { position: 'fixed' as const, top: `${top}px`, left: `${left}px`, width: `${cardWidth}px`, zIndex: props.zIndex + 1, } }) </script> <template> <div role="dialog" aria-modal="true" :class=" cn( 'relative space-y-3 rounded-lg border p-4 shadow-lg', type === 'primary' ? 'bg-primary text-primary-foreground border-primary' : 'bg-popover text-popover-foreground', ) " :style="cardStyle" > <button type="button" class="hover:bg-foreground/10 focus-visible:ring-ring absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none" aria-label="Close tour" @click="$emit('skip')" > <X class="size-4" aria-hidden="true" /> </button> <img v-if="cover" :src="cover" alt="" class="w-full rounded-md" /> <div> <div class="pr-6 font-semibold">{{ title }}</div> <div v-if="description" class="mt-1 text-sm opacity-90">{{ description }}</div> </div> <div class="flex items-center justify-between gap-2 pt-2"> <div class="text-xs tabular-nums opacity-70" aria-live="polite">{{ current + 1 }} / {{ total }}</div> <div class="flex gap-2"> <Button v-if="!isFirst" size="sm" :variant="type === 'primary' ? 'secondary' : 'outline'" @click="$emit('prev')" > {{ prevText }} </Button> <Button v-if="!isLast" size="sm" :variant="type === 'primary' ? 'secondary' : 'default'" @click="$emit('next')"> {{ nextText }} </Button> <Button v-else size="sm" :variant="type === 'primary' ? 'secondary' : 'default'" @click="$emit('finish')"> Finish </Button> </div> </div> </div> </template> -
app/components/ui/tour/use-tour-target.ts 1.8 kB
import { onBeforeUnmount, ref, watch, type Ref } from 'vue' export type TourTarget = string | (() => HTMLElement | null) | HTMLElement | null export interface TargetRect { x: number y: number width: number height: number } export function useTourTarget(target: Ref<TourTarget | undefined>) { const rect = ref<TargetRect | null>(null) const element = ref<HTMLElement | null>(null) let resizeObs: ResizeObserver | null = null let raf = 0 function resolve(): HTMLElement | null { const t = target.value if (!t) return null if (typeof t === 'string') return document.querySelector(t) as HTMLElement | null if (typeof t === 'function') return t() return t } function measure() { cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { if (!element.value) { rect.value = null return } const r = element.value.getBoundingClientRect() rect.value = { x: r.left, y: r.top, width: r.width, height: r.height } }) } function attach() { detach() element.value = resolve() if (!element.value) { rect.value = null return } measure() if (typeof ResizeObserver !== 'undefined') { resizeObs = new ResizeObserver(measure) resizeObs.observe(element.value) resizeObs.observe(document.documentElement) } window.addEventListener('scroll', measure, { passive: true, capture: true }) window.addEventListener('resize', measure, { passive: true }) } function detach() { resizeObs?.disconnect() resizeObs = null window.removeEventListener('scroll', measure, true) window.removeEventListener('resize', measure) cancelAnimationFrame(raf) } watch(target, attach, { immediate: false }) onBeforeUnmount(detach) return { element, rect, attach, detach, measure } } -
app/components/ui/tour/index.ts 0.3 kB
import type { TourTarget } from './use-tour-target' export interface TourStep { target?: TourTarget title: string description?: string cover?: string mask?: boolean nextButtonText?: string prevButtonText?: string } export type { TourTarget } from './use-tour-target' export { default as Tour } from './Tour.vue'
Raw manifest: https://uipkge.dev/r/vue/tour.json