UIPackage

Tour

React overlay
Edit on GitHub

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://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