Tour
React overlayMulti-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 Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/tour.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/tour.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/tour.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/tour.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/tour
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
rect | TargetRect | null | — | required |
zIndex | number | — | required |
opacity | number | — | optional |
padding | number | — | optional |
radius | number | — | 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 (2)
-
components/ui/tour/tour.tsx 10.9 kB
'use client' import * as React from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' export type TourTarget = string | (() => HTMLElement | null) | HTMLElement | null export interface TargetRect { x: number y: number width: number height: number } export interface TourStep { target?: TourTarget title: string description?: string cover?: string mask?: boolean nextButtonText?: string prevButtonText?: string } // ── useTourTarget ──────────────────────────────────────────────────────────── // Measures the current step's target element (getBoundingClientRect) and keeps // the rect in sync on scroll / resize / element resize. `attach` is called // imperatively when the step or open state changes; `measure` re-reads the rect. function useTourTarget(target: TourTarget | undefined) { const [rect, setRect] = React.useState<TargetRect | null>(null) const elementRef = React.useRef<HTMLElement | null>(null) const resizeObsRef = React.useRef<ResizeObserver | null>(null) const rafRef = React.useRef(0) const targetRef = React.useRef(target) targetRef.current = target const resolve = React.useCallback((): HTMLElement | null => { const t = targetRef.current if (!t) return null if (typeof t === 'string') return document.querySelector(t) as HTMLElement | null if (typeof t === 'function') return t() return t }, []) const measure = React.useCallback(() => { cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(() => { if (!elementRef.current) { setRect(null) return } const r = elementRef.current.getBoundingClientRect() setRect({ x: r.left, y: r.top, width: r.width, height: r.height }) }) }, []) const detach = React.useCallback(() => { resizeObsRef.current?.disconnect() resizeObsRef.current = null window.removeEventListener('scroll', measure, true) window.removeEventListener('resize', measure) cancelAnimationFrame(rafRef.current) }, [measure]) const attach = React.useCallback(() => { detach() elementRef.current = resolve() if (!elementRef.current) { setRect(null) return } measure() if (typeof ResizeObserver !== 'undefined') { resizeObsRef.current = new ResizeObserver(measure) resizeObsRef.current.observe(elementRef.current) resizeObsRef.current.observe(document.documentElement) } window.addEventListener('scroll', measure, { passive: true, capture: true }) window.addEventListener('resize', measure, { passive: true }) }, [detach, measure, resolve]) React.useEffect(() => detach, [detach]) return { rect, attach, detach, measure } } // ── TourMask ───────────────────────────────────────────────────────────────── interface TourMaskProps { rect: TargetRect | null zIndex: number opacity?: number padding?: number radius?: number } function TourMask({ rect, zIndex, opacity = 0.5, padding = 4, radius = 6 }: TourMaskProps) { const cutout = rect ? { x: rect.x - padding, y: rect.y - padding, w: rect.width + padding * 2, h: rect.height + padding * 2, } : null return ( <svg className="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" /> {cutout && ( <rect 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)" className="pointer-events-auto" style={{ transition: 'all 200ms ease' }} /> </svg> ) } // ── TourCard ───────────────────────────────────────────────────────────────── interface TourCardProps { title: string description?: string cover?: string rect: TargetRect | null total: number current: number prevText?: string nextText?: string type?: 'default' | 'primary' zIndex: number onPrev: () => void onNext: () => void onFinish: () => void onSkip: () => void } function TourCard({ title, description, cover, rect, total, current, prevText = 'Previous', nextText = 'Next', type = 'default', zIndex, onPrev, onNext, onFinish, onSkip, }: TourCardProps) { const isLast = current === total - 1 const isFirst = current === 0 const cardStyle = React.useMemo<React.CSSProperties>(() => { const cardWidth = 320 const margin = 12 if (!rect) { return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: `${cardWidth}px`, zIndex: zIndex + 1, } } const { x, y, width, height } = 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', top: `${top}px`, left: `${left}px`, width: `${cardWidth}px`, zIndex: zIndex + 1, } }, [rect, zIndex]) return ( <div role="dialog" aria-modal="true" className={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" className="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" onClick={onSkip} > <X className="size-4" aria-hidden="true" /> </button> {cover && <img src={cover} alt="" className="w-full rounded-md" />} <div> <div className="pr-6 font-semibold">{title}</div> {description && <div className="mt-1 text-sm opacity-90">{description}</div>} </div> <div className="flex items-center justify-between gap-2 pt-2"> <div className="text-xs tabular-nums opacity-70" aria-live="polite"> {current + 1} / {total} </div> <div className="flex gap-2"> {!isFirst && ( <Button size="sm" variant={type === 'primary' ? 'secondary' : 'outline'} onClick={onPrev}> {prevText} </Button> )} {!isLast ? ( <Button size="sm" variant={type === 'primary' ? 'secondary' : 'default'} onClick={onNext}> {nextText} </Button> ) : ( <Button size="sm" variant={type === 'primary' ? 'secondary' : 'default'} onClick={onFinish}> Finish </Button> )} </div> </div> </div> ) } // ── Tour ───────────────────────────────────────────────────────────────────── export interface TourProps { open?: boolean current?: number steps: TourStep[] mask?: boolean type?: 'default' | 'primary' zIndex?: number /** Controlled open updates — mirrors Vue's `update:open`. */ onOpenChange?: (open: boolean) => void /** Controlled current-step updates — mirrors Vue's `update:current`. */ onCurrentChange?: (current: number) => void /** Fired whenever the active step changes. */ onChange?: (current: number) => void /** Fired when the user presses Finish on the last step. */ onFinish?: () => void /** Fired when the user dismisses the tour (close button / Escape). */ onClose?: () => void } function Tour({ open = false, current = 0, steps, mask = true, type = 'default', zIndex = 1000, onOpenChange, onCurrentChange, onChange, onFinish, onClose, }: TourProps) { const [stepIndex, setStepIndex] = React.useState(current) // Mirror the `current` prop into local state (Vue `watch(() => props.current)`). React.useEffect(() => { setStepIndex(current) }, [current]) const currentStep = steps[stepIndex] ?? null const { rect, attach, measure } = useTourTarget(currentStep?.target) // Attach + scroll the target into view on open / step change (Vue's watch // on [open, stepIndex] with immediate + nextTick). The effect runs after // paint, which is React's equivalent of awaiting nextTick. React.useEffect(() => { if (!open) return attach() const t = currentStep?.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' }) const id = setTimeout(measure, 320) return () => clearTimeout(id) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, stepIndex]) const setStep = React.useCallback( (i: number) => { setStepIndex(i) onCurrentChange?.(i) onChange?.(i) }, [onCurrentChange, onChange], ) const next = React.useCallback(() => { if (stepIndex < steps.length - 1) setStep(stepIndex + 1) }, [stepIndex, steps.length, setStep]) const prev = React.useCallback(() => { if (stepIndex > 0) setStep(stepIndex - 1) }, [stepIndex, setStep]) const finish = React.useCallback(() => { onFinish?.() onOpenChange?.(false) }, [onFinish, onOpenChange]) const skip = React.useCallback(() => { onClose?.() onOpenChange?.(false) }, [onClose, onOpenChange]) // Escape closes the tour while open. React.useEffect(() => { if (typeof document === 'undefined') return if (!open) return function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault() skip() } } document.addEventListener('keydown', onKeydown) return () => document.removeEventListener('keydown', onKeydown) }, [open, skip]) const showMask = currentStep?.mask !== undefined ? currentStep.mask : mask if (typeof document === 'undefined') return null if (!open || !currentStep) return null return createPortal( <> {showMask && <TourMask rect={rect} zIndex={zIndex} />} <TourCard title={currentStep.title} description={currentStep.description} cover={currentStep.cover} rect={rect} total={steps.length} current={stepIndex} prevText={currentStep.prevButtonText} nextText={currentStep.nextButtonText} type={type} zIndex={zIndex} onPrev={prev} onNext={next} onFinish={finish} onSkip={skip} /> </>, document.body, ) } export { Tour } -
components/ui/tour/index.ts 0.1 kB
export { Tour, type TourProps, type TourStep, type TourTarget, type TargetRect } from './tour'
Raw manifest: https://react.uipkge.dev/r/react/tour.json