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 Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/tour.json
Named registry: npx shadcn@latest add @uipkge-react/tour Installs to: components/ui/tour/

Examples

Loading interactive previews…

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
  finishButtonText?: string
}

npm dependencies

Includes

Files installed (2)

  • components/ui/tour/tour.tsx 15.2 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
      finishButtonText?: 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)
        elementRef.current = null
      }, [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 }
    }
    
    function prefersReducedMotion(): boolean {
      if (typeof window === 'undefined') return false
      return window.matchMedia('(prefers-reduced-motion: reduce)').matches
    }
    
    // ── 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) {
      // Unique mask id so multiple open tours (or other SVG masks on the page) never collide.
      const reactId = React.useId()
      const maskId = `uipkge-tour-mask-${reactId.replace(/:/g, '')}`
      const reduceMotion = prefersReducedMotion()
    
      const cutout = rect
        ? {
            x: rect.x - padding,
            y: rect.y - padding,
            w: rect.width + padding * 2,
            h: rect.height + padding * 2,
          }
        : null
    
      /**
       * 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 = cutout
        ? `polygon(evenodd, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${cutout.x}px ${cutout.y}px, ${cutout.x}px ${cutout.y + cutout.h}px, ${cutout.x + cutout.w}px ${cutout.y + cutout.h}px, ${cutout.x + cutout.w}px ${cutout.y}px, ${cutout.x}px ${cutout.y}px)`
        : undefined
    
      return (
        <>
          {/* Visual dim with rounded cutout (decorative only — no hit testing). */}
          <svg
            className="pointer-events-none fixed inset-0"
            style={{ zIndex }}
            width="100%"
            height="100%"
            aria-hidden="true"
          >
            <defs>
              <mask id={maskId}>
                <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(#${maskId})`}
              style={reduceMotion ? undefined : { transition: 'all 200ms ease' }}
            />
          </svg>
          {/* Hit layer: blocks clicks outside the cutout; hole is click-through. */}
          <div
            className="fixed inset-0"
            aria-hidden="true"
            style={{
              zIndex,
              clipPath: hitClipPath,
              background: 'transparent',
            }}
          />
        </>
      )
    }
    
    // ── TourCard ─────────────────────────────────────────────────────────────────
    const FOCUSABLE =
      'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
    
    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
      autofocus?: boolean
      onPrev: () => void
      onNext: () => void
      onFinish: () => void
      onSkip: () => void
    }
    
    function TourCard({
      title,
      description,
      cover,
      rect,
      total,
      current,
      prevText = 'Previous',
      nextText = 'Next',
      finishText = 'Finish',
      type = 'default',
      zIndex,
      autofocus = false,
      onPrev,
      onNext,
      onFinish,
      onSkip,
    }: TourCardProps) {
      const isLast = current === total - 1
      const isFirst = current === 0
      const titleId = React.useId()
      const descriptionId = React.useId()
      const cardRef = React.useRef<HTMLDivElement>(null)
      /** Measured card height used for placement; falls back to estimate until laid out. */
      const [measuredHeight, setMeasuredHeight] = React.useState(0)
    
      // Move focus into the card when opened or when the step changes.
      React.useEffect(() => {
        if (!autofocus) return
        cardRef.current?.focus()
      }, [autofocus, current, title])
    
      // Reposition from actual card height after open / step content change.
      React.useEffect(() => {
        const el = cardRef.current
        if (!el) return
        const measure = () => setMeasuredHeight(el.getBoundingClientRect().height)
        measure()
        if (typeof ResizeObserver === 'undefined') return
        const ro = new ResizeObserver(measure)
        ro.observe(el)
        return () => ro.disconnect()
      }, [current, title, description, cover])
    
      /** Keep Tab cycling inside the dialog while aria-modal is asserted. */
      function onKeydown(e: React.KeyboardEvent<HTMLDivElement>) {
        if (e.key !== 'Tab' || !cardRef.current) return
        // Prefer getClientRects over offsetParent — fixed-position descendants report null offsetParent.
        const list = Array.from(cardRef.current.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.current) {
            e.preventDefault()
            last.focus()
          }
        } else if (document.activeElement === last) {
          e.preventDefault()
          first.focus()
        }
      }
    
      const cardStyle = React.useMemo<React.CSSProperties>(() => {
        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`,
            zIndex: 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`,
          zIndex: zIndex + 1,
        }
      }, [rect, zIndex, cover, measuredHeight])
    
      return (
        <div
          ref={cardRef}
          data-slot="tour-card"
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={description ? descriptionId : undefined}
          tabIndex={-1}
          className={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}
          onKeyDown={onKeydown}
        >
          <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 id={titleId} className="pr-6 font-semibold">
              {title}
            </div>
            {description && (
              <div id={descriptionId} 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" aria-atomic="true">
              {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}>
                  {finishText}
                </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)
      const previousFocusRef = React.useRef<HTMLElement | null>(null)
      const wasOpenRef = React.useRef(false)
    
      // Mirror the `current` prop into local state (Vue `watch(() => props.current)`).
      React.useEffect(() => {
        setStepIndex(current)
      }, [current])
    
      const currentStep = steps[stepIndex] ?? null
    
      const { rect, attach, detach, measure } = useTourTarget(currentStep?.target)
    
      // Attach + scroll the target into view on open / step change; detach + restore
      // focus when the tour closes.
      React.useEffect(() => {
        if (!open) {
          detach()
          if (wasOpenRef.current) {
            previousFocusRef.current?.focus?.()
            previousFocusRef.current = null
          }
          wasOpenRef.current = false
          return
        }
        if (!wasOpenRef.current) {
          previousFocusRef.current = (document.activeElement as HTMLElement | null) ?? null
          wasOpenRef.current = true
        }
        attach()
        const t = currentStep?.target
        if (t) {
          const el =
            typeof t === 'string' ? (document.querySelector(t) as HTMLElement | null) : typeof t === 'function' ? t() : t
          const reduce = prefersReducedMotion()
          el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' })
          // Re-measure after smooth scroll settles; skip long wait when reduce.
          const id = setTimeout(measure, reduce ? 0 : 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}
            finishText={currentStep.finishButtonText}
            type={type}
            zIndex={zIndex}
            autofocus
            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://uipkge.dev/r/react/tour.json