Tour
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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tour.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tour.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tour.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tour.jsonManifest:
https://uipkge.dev/r/svelte/tour.jsonInstalls to:src/lib/components/ui/tour/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
open | boolean | — | optional |
current | number | — | optional |
steps | TourStep[] | — | required |
mask | boolean | true | optional |
type | 'default' | 'primary' | default | optional |
zIndex | number | 1000 | optional |
onchange | (v: number) => void | — | optional |
onfinish | () => void | — | optional |
onclose | () => void | — | optional |
Schema
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
}npm dependencies
Includes
Files installed (5)
src/lib/components/ui/tour/Tour.svelte4.2 kB
<script lang="ts" module> import type { TourTarget } from './tour-target.svelte' export interface TourStep { target?: TourTarget title: string description?: string cover?: string mask?: boolean nextButtonText?: string prevButtonText?: string finishButtonText?: string } export interface TourProps { open?: boolean current?: number steps: TourStep[] mask?: boolean type?: 'default' | 'primary' zIndex?: number onchange?: (v: number) => void onfinish?: () => void onclose?: () => void } </script> <script lang="ts"> import { tick } from 'svelte' import TourMask from './TourMask.svelte' import TourCard from './TourCard.svelte' import { createTourTarget } from './tour-target.svelte' let { open = $bindable(false), current = $bindable(0), steps, mask = true, type = 'default', zIndex = 1000, onchange, onfinish, onclose, }: TourProps = $props() let stepIndex = $state(current) $effect(() => { if (current !== stepIndex) stepIndex = current }) const currentStep = $derived(steps[stepIndex] ?? null) const tracker = createTourTarget(() => currentStep?.target) // Client-only gate: the overlay portals to <body>, so it renders after // hydration to keep SSR markup identical. let mounted = $state(false) $effect(() => { mounted = true }) /** Element that held focus before the tour opened — restored on close. */ let previousFocus: HTMLElement | null = null let wasOpen = false $effect(() => { const isOpen = open // Track the step so re-anchoring runs on step change too. const idx = stepIndex void idx if (!isOpen) { tracker.detach() if (wasOpen) { previousFocus?.focus?.() previousFocus = null } wasOpen = false return } if (!wasOpen && typeof document !== 'undefined') { previousFocus = (document.activeElement as HTMLElement | null) ?? null } wasOpen = true tick().then(() => { tracker.attach() const t = steps[stepIndex]?.target if (t && typeof document !== 'undefined') { 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(() => tracker.measure(), reduce ? 0 : 320) } }) }) function setStep(i: number) { stepIndex = i current = i onchange?.(i) } function next() { if (stepIndex < steps.length - 1) setStep(stepIndex + 1) } function prev() { if (stepIndex > 0) setStep(stepIndex - 1) } function finish() { onfinish?.() open = false } function skip() { onclose?.() open = false } $effect(() => { if (!open || typeof document === 'undefined') return function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault() skip() } } document.addEventListener('keydown', onKeydown) return () => document.removeEventListener('keydown', onKeydown) }) const showMask = $derived(currentStep?.mask ?? mask) function portal(node: HTMLElement) { document.body.appendChild(node) return { destroy() { node.remove() }, } } </script> {#if mounted && open && currentStep} <div use:portal style="display: contents"> {#if showMask} <TourMask rect={tracker.rect} zIndex={zIndex} /> {/if} <TourCard title={currentStep.title} description={currentStep.description} cover={currentStep.cover} rect={tracker.rect} total={steps.length} current={stepIndex} prevText={currentStep.prevButtonText} nextText={currentStep.nextButtonText} finishText={currentStep.finishButtonText} {type} {zIndex} autofocus onprev={prev} onnext={next} onfinish={finish} onskip={skip} /> </div> {/if}src/lib/components/ui/tour/TourMask.svelte2.2 kB
<script lang="ts" module> import type { TargetRect } from './tour-target.svelte' export interface TourMaskProps { rect: TargetRect | null zIndex: number opacity?: number padding?: number radius?: number } </script> <script lang="ts"> let { rect, zIndex, opacity = 0.5, padding = 4, radius = 6 }: TourMaskProps = $props() // Unique mask id so multiple open tours (or other SVG masks on the page) never collide. const uid = $props.id() const maskId = `uipkge-tour-mask-${uid}` const cutout = $derived.by(() => { const r = rect if (!r) return null return { x: r.x - padding, y: r.y - padding, w: r.width + padding * 2, h: r.height + padding * 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 = $derived.by(() => { const c = cutout 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 = $derived( typeof window === 'undefined' ? false : window.matchMedia('(prefers-reduced-motion: reduce)').matches, ) </script> <!-- Visual dim with rounded cutout (decorative only — no hit testing). --> <svg class="pointer-events-none fixed inset-0" style="z-index: {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" /> {#if cutout} <rect x={cutout.x} y={cutout.y} width={cutout.w} height={cutout.h} rx={radius} fill="black" /> {/if} </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="z-index: {zIndex}; clip-path: {hitClipPath ?? 'none'}; background: transparent" ></div>src/lib/components/ui/tour/TourCard.svelte5.8 kB
<script lang="ts" module> import type { TargetRect } from './tour-target.svelte' export interface TourCardProps { 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 onprev?: () => void onnext?: () => void onfinish?: () => void onskip?: () => void } </script> <script lang="ts"> import { tick } from 'svelte' import { X } from '@lucide/svelte' import { Button } from '$lib/components/ui/button' import { cn } from '$lib/utils' let { title, description, cover, rect, total, current, prevText = 'Previous', nextText = 'Next', finishText = 'Finish', type = 'default', zIndex, autofocus = false, onprev, onnext, onfinish, onskip, }: TourCardProps = $props() const isLast = $derived(current === total - 1) const isFirst = $derived(current === 0) const uid = $props.id() const titleId = `${uid}-title` const descriptionId = `${uid}-description` let cardRef = $state<HTMLElement | null>(null) /** Measured card height used for placement; falls back to estimate until laid out. */ let measuredHeight = $state(0) const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' function focusCard() { tick().then(() => { cardRef?.focus() }) } $effect(() => { const el = cardRef if (!el || typeof ResizeObserver === 'undefined') { if (el) measuredHeight = el.getBoundingClientRect().height return } const ro = new ResizeObserver(() => { measuredHeight = el.getBoundingClientRect().height }) ro.observe(el) measuredHeight = el.getBoundingClientRect().height return () => ro.disconnect() }) $effect(() => { // Re-run (and refocus) after step content swaps (title/cover/description). void current void title void description void cover if (autofocus) focusCard() }) /** Keep Tab cycling inside the dialog while aria-modal is asserted. */ function onKeydown(e: KeyboardEvent) { if (e.key !== 'Tab' || !cardRef) return // Prefer getClientRects over offsetParent — fixed-position descendants report null offsetParent. const list = Array.from(cardRef.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) { e.preventDefault() last.focus() } } else if (document.activeElement === last) { e.preventDefault() first.focus() } } const cardStyle = $derived.by(() => { const cardWidth = 320 const margin = 12 const edgePadding = 8 // Prefer measured height; estimate only before first layout (cover makes card taller). const cardHeight = measuredHeight || (cover ? 320 : 200) if (!rect) { return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: ${cardWidth}px; z-index: ${zIndex + 1}` } const { x, y, height } = 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; top: ${top}px; left: ${left}px; width: ${cardWidth}px; z-index: ${zIndex + 1}` }) </script> <div bind:this={cardRef} data-uipkge 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' ? 'border-primary bg-primary text-primary-foreground' : 'bg-popover text-popover-foreground', )} style={cardStyle} onkeydown={onKeydown} > <button type="button" class="absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" aria-label="Close tour" onclick={() => onskip?.()} > <X class="size-4" aria-hidden="true" /> </button> {#if cover} <img src={cover} alt="" class="w-full rounded-md" /> {/if} <div> <div id={titleId} class="pr-6 font-semibold">{title}</div> {#if description} <div id={descriptionId} class="mt-1 text-sm opacity-90">{description}</div> {/if} </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"> {#if !isFirst} <Button size="sm" variant={type === 'primary' ? 'secondary' : 'outline'} onclick={() => onprev?.()}> {prevText} </Button> {/if} {#if !isLast} <Button size="sm" variant={type === 'primary' ? 'secondary' : 'default'} onclick={() => onnext?.()}> {nextText} </Button> {:else} <Button size="sm" variant={type === 'primary' ? 'secondary' : 'default'} onclick={() => onfinish?.()}> {finishText} </Button> {/if} </div> </div> </div>src/lib/components/ui/tour/tour-target.svelte.ts2 kB
import { onDestroy } from 'svelte' export type TourTarget = string | (() => HTMLElement | null) | HTMLElement | null export interface TargetRect { x: number y: number width: number height: number } export function createTourTarget(getTarget: () => TourTarget | undefined) { let rect = $state<TargetRect | null>(null) let element: HTMLElement | null = null let resizeObs: ResizeObserver | null = null let raf = 0 function resolve(): HTMLElement | null { const t = getTarget() if (!t || typeof document === 'undefined') return null if (typeof t === 'string') return document.querySelector(t) as HTMLElement | null if (typeof t === 'function') return t() return t } function measure() { if (typeof requestAnimationFrame === 'undefined') return cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { if (!element) { rect = null return } const r = element.getBoundingClientRect() rect = { x: r.left, y: r.top, width: r.width, height: r.height } }) } function attach() { detach() element = resolve() if (!element) { rect = null return } measure() if (typeof ResizeObserver !== 'undefined' && typeof document !== 'undefined') { resizeObs = new ResizeObserver(measure) resizeObs.observe(element) resizeObs.observe(document.documentElement) } if (typeof window !== 'undefined') { window.addEventListener('scroll', measure, { passive: true, capture: true }) window.addEventListener('resize', measure, { passive: true }) } } function detach() { resizeObs?.disconnect() resizeObs = null if (typeof window !== 'undefined') { window.removeEventListener('scroll', measure, true) window.removeEventListener('resize', measure) } if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(raf) } onDestroy(detach) return { get rect() { return rect }, get element() { return element }, attach, detach, measure, } }src/lib/components/ui/tour/index.ts0.1 kB
export { default as Tour, type TourProps, type TourStep } from './Tour.svelte' export type { TourTarget, TargetRect } from './tour-target.svelte'
Raw manifest:https://uipkge.dev/r/svelte/tour.json