Tour
tour ui Multi-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 Named registry:
npx shadcn-vue@latest add @uipkge/tour Installs to: app/components/ui/tour/ Examples
Loading interactive previews…
Loading interactive previews…
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
finishButtonText?: string
} npm dependencies
Includes
Files installed (5)
-
app/components/ui/tour/Tour.vue 4 kB
<script setup lang="ts"> import { computed, nextTick, onBeforeUnmount, 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, detach, measure } = useTourTarget(targetRef) /** Element that held focus before the tour opened — restored on close. */ let previousFocus: HTMLElement | null = null watch( [() => props.open, stepIndex], async ([open], oldVal) => { // oldVal is undefined on the immediate first run — never destructure it. const wasOpen = oldVal?.[0] ?? false if (!open) { detach() if (wasOpen) { previousFocus?.focus?.() previousFocus = null } return } if (!wasOpen && typeof document !== 'undefined') { previousFocus = (document.activeElement as HTMLElement | null) ?? null } 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 const reduce = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' }) // Re-measure after smooth scroll settles; skip long wait when reduce. setTimeout(measure, reduce ? 0 : 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) }, { immediate: true }, ) onBeforeUnmount(() => { if (typeof document !== 'undefined') { document.removeEventListener('keydown', onKeydown) } detach() previousFocus = null }) 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" :finish-text="currentStep.finishButtonText" :type="type" :z-index="zIndex" autofocus @prev="prev" @next="next" @finish="finish" @skip="skip" /> </template> </Teleport> </template> -
app/components/ui/tour/TourMask.vue 2.5 kB
<script setup lang="ts"> import { computed, useId } 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) // Unique mask id so multiple open tours (or other SVG masks on the page) never collide. const maskId = `uipkge-tour-mask-${useId()}` 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, } }) /** * Clip-path leaves a hole over the target so pointer events pass through to the * highlighted element. SVG mask alone does not punch a hit-test hole. */ const hitClipPath = computed(() => { const c = cutout.value if (!c) return undefined const { x, y, w, h } = c return `polygon(evenodd, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)` }) const reduceMotion = computed(() => { if (typeof window === 'undefined') return false return window.matchMedia('(prefers-reduced-motion: reduce)').matches }) </script> <template> <!-- Visual dim with rounded cutout (decorative only — no hit testing). --> <svg class="pointer-events-none fixed inset-0" :style="{ zIndex, '--tour-padding': `${padding}px`, '--tour-radius': `${radius}px` }" width="100%" height="100%" aria-hidden="true" > <defs> <mask :id="maskId"> <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(#${maskId})`" :style="reduceMotion ? undefined : { transition: 'all 200ms ease' }" /> </svg> <!-- Hit layer: blocks clicks outside the cutout; hole is click-through. --> <div class="fixed inset-0" aria-hidden="true" :style="{ zIndex, clipPath: hitClipPath, // Transparent fill still receives pointer events where not clipped. background: 'transparent', }" /> </template> -
app/components/ui/tour/TourCard.vue 6.1 kB
<script setup lang="ts"> import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } 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 finishText?: string type?: 'default' | 'primary' zIndex: number /** When true, move focus into the card (on open / step change). */ autofocus?: boolean }>(), { prevText: 'Previous', nextText: 'Next', finishText: 'Finish', type: 'default', autofocus: false, }, ) 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 titleId = useId() const descriptionId = useId() const cardRef = ref<HTMLElement | null>(null) /** Measured card height used for placement; falls back to estimate until laid out. */ const measuredHeight = ref(0) let resizeObs: ResizeObserver | null = null const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' function focusCard() { nextTick(() => { cardRef.value?.focus() }) } function measureCard() { const el = cardRef.value if (!el) return measuredHeight.value = el.getBoundingClientRect().height } function attachResizeObserver() { resizeObs?.disconnect() resizeObs = null const el = cardRef.value if (!el || typeof ResizeObserver === 'undefined') { measureCard() return } resizeObs = new ResizeObserver(() => measureCard()) resizeObs.observe(el) measureCard() } onMounted(() => { attachResizeObserver() if (props.autofocus) focusCard() }) watch( () => [props.current, props.title, props.description, props.cover] as const, () => { if (props.autofocus) focusCard() // Re-measure after step content swaps (title/cover/description). nextTick(attachResizeObserver) }, ) onBeforeUnmount(() => { resizeObs?.disconnect() resizeObs = null }) /** Keep Tab cycling inside the dialog while aria-modal is asserted. */ function onKeydown(e: KeyboardEvent) { if (e.key !== 'Tab' || !cardRef.value) return // Prefer getClientRects over offsetParent — fixed-position descendants report null offsetParent. const list = Array.from(cardRef.value.querySelectorAll<HTMLElement>(FOCUSABLE)).filter( (el) => el.getClientRects().length > 0, ) if (list.length === 0) return const first = list[0]! const last = list[list.length - 1]! if (e.shiftKey) { if (document.activeElement === first || document.activeElement === cardRef.value) { e.preventDefault() last.focus() } } else if (document.activeElement === last) { e.preventDefault() first.focus() } } const cardStyle = computed(() => { const cardWidth = 320 const margin = 12 const edgePadding = 8 // Prefer measured height; estimate only before first layout (cover makes card taller). const cardHeight = measuredHeight.value || (props.cover ? 320 : 200) 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, height } = props.rect const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768 const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024 const placeBelow = y + height + margin + cardHeight < viewportH const top = placeBelow ? y + height + margin : Math.max(edgePadding, y - margin - cardHeight) let left = x if (left + cardWidth > viewportW - edgePadding) { left = viewportW - cardWidth - edgePadding } if (left < edgePadding) left = edgePadding return { position: 'fixed' as const, top: `${top}px`, left: `${left}px`, width: `${cardWidth}px`, zIndex: props.zIndex + 1, } }) defineExpose({ focus: focusCard, el: cardRef }) </script> <template> <div ref="cardRef" data-slot="tour-card" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="description ? descriptionId : undefined" tabindex="-1" :class=" cn( 'relative space-y-3 rounded-lg border p-4 shadow-lg outline-none', type === 'primary' ? 'bg-primary text-primary-foreground border-primary' : 'bg-popover text-popover-foreground', ) " :style="cardStyle" @keydown="onKeydown" > <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 :id="titleId" class="pr-6 font-semibold">{{ title }}</div> <div v-if="description" :id="descriptionId" 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" aria-atomic="true"> {{ 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')"> {{ finishText }} </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 finishButtonText?: string } export type { TourTarget } from './use-tour-target' export { default as Tour } from './Tour.vue'
Raw manifest: https://uipkge.dev/r/vue/tour.json