UIPackage
Menu

Framework

Change language

Boilerplate repo

Tour

tour ui
Boilerplate repo

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
Named registry: npx shadcn-vue@latest add @uipkge/tour Installs to: app/components/ui/tour/

Examples

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