
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
touruiMulti-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/tourInstalls to:app/components/ui/tour/| 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 |
Type aliases from this item's source — use them to shape the data you pass in.
TargetRectinterface TargetRect {
x: number
y: number
width: number
height: number
}TourStepinterface TourStep {
target?: TourTarget
title: string
description?: string
cover?: string
mask?: boolean
nextButtonText?: string
prevButtonText?: string
finishButtonText?: string
}<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>
<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>
<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>
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 }
}
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