UIPackage

Tour

Vue overlay
Edit on GitHub

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

$ npx 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