{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-tour",
  "type": "registry:ui",
  "title": "Product Tour",
  "description": "An onboarding tour that dims the page, springs a spotlight between targets and anchors a step card with collision handling.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/product-tour.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useSpring } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\ntype Placement = \"auto\" | \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport interface ProductTourStep {\n  /** A CSS selector (resolved inside `root`), a ref, or a function returning the element. Omit for a centred step. */\n  target?: string | React.RefObject<HTMLElement | null> | (() => HTMLElement | null);\n  title: string;\n  body: React.ReactNode;\n  /** Overrides the tour's placement for this step. */\n  placement?: Placement;\n}\n\nexport interface ProductTourProps {\n  steps: ProductTourStep[];\n  /** Whether the tour is running (controlled). */\n  open: boolean;\n  /** Called with false when the tour is finished, skipped or dismissed with Escape. */\n  onOpenChange: (open: boolean) => void;\n  /** Step shown when the tour opens. */\n  defaultStep?: number;\n  /** Called whenever the step changes. */\n  onStepChange?: (index: number) => void;\n  /** Called when the last step's Done button is pressed. */\n  onFinish?: () => void;\n  /** Scope for selector targets. Defaults to the document. */\n  root?: React.RefObject<HTMLElement | null>;\n  /**\n   * true: a modal <dialog> over the whole viewport (focus trapped, page inert).\n   * false: the overlay fills the nearest positioned ancestor and the popover is a non-modal dialog.\n   */\n  modal?: boolean;\n  /** Space between the target and the spotlight edge, in px. */\n  spotlightPadding?: number;\n  /** Corner radius of the spotlight, in px. */\n  spotlightRadius?: number;\n  /** Opacity of the dimmed overlay (0–1). */\n  overlayOpacity?: number;\n  /** Preferred popover side; \"auto\" picks the side with room. */\n  placement?: Placement;\n  /** Show progress dots in the popover. */\n  showProgress?: boolean;\n  /** Move focus into the popover when the tour opens. Turn off only for non-interactive previews. */\n  autoFocus?: boolean;\n  /** Spring for the spotlight and popover moving between targets. */\n  spring?: Spring;\n  /** Spotlight ring, progress and primary button colour. */\n  accent?: string;\n  /** Button labels. */\n  labels?: Partial<Record<\"next\" | \"back\" | \"done\" | \"skip\", string>>;\n  className?: string;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst MARGIN = 12; // px kept between the popover and the bounds\nconst OFFSET = 12; // px between the spotlight and the popover\n\ntype Box = { left: number; top: number; width: number; height: number };\n\nfunction resolveTarget(target: ProductTourStep[\"target\"], root: HTMLElement | null): HTMLElement | null {\n  if (!target) return null;\n  if (typeof target === \"function\") return target();\n  if (typeof target === \"string\") return (root ?? document).querySelector<HTMLElement>(target);\n  return target.current;\n}\n\nfunction isScrollable(el: HTMLElement) {\n  const style = getComputedStyle(el);\n  const y = /(auto|scroll|overlay)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 1;\n  const x = /(auto|scroll|overlay)/.test(style.overflowX) && el.scrollWidth > el.clientWidth + 1;\n  return x || y;\n}\n\n/** Distance to scroll so `inner` sits inside `outer` with a margin (0 when it already does). */\nfunction delta(innerStart: number, innerEnd: number, outerStart: number, outerEnd: number, margin: number) {\n  const size = innerEnd - innerStart;\n  if (size + margin * 2 > outerEnd - outerStart) return innerStart - outerStart - margin; // too big: align the start\n  if (innerStart < outerStart + margin) return innerStart - outerStart - margin;\n  if (innerEnd > outerEnd - margin) return innerEnd - outerEnd + margin;\n  return 0;\n}\n\n/** Brings the target into view through every scrollable ancestor, then the window. Never uses scrollIntoView. */\nfunction revealTarget(el: HTMLElement, smooth: boolean, margin: number) {\n  const behavior: ScrollBehavior = smooth ? \"smooth\" : \"auto\";\n  let node = el.parentElement;\n  while (node && node !== document.body && node !== document.documentElement) {\n    if (isScrollable(node)) {\n      const outer = node.getBoundingClientRect();\n      const inner = el.getBoundingClientRect();\n      const top = delta(inner.top, inner.bottom, outer.top, outer.bottom, margin);\n      const left = delta(inner.left, inner.right, outer.left, outer.right, margin);\n      if (top || left) node.scrollBy({ top, left, behavior });\n    }\n    node = node.parentElement;\n  }\n  const rect = el.getBoundingClientRect();\n  // Leave extra room so the popover can sit above or below the target.\n  const top = delta(rect.top, rect.bottom, 0, window.innerHeight, Math.min(160, window.innerHeight / 4));\n  if (top) window.scrollBy({ top, behavior });\n}\n\n/** Picks a side for the popover that fits inside the bounds, then clamps it. */\nfunction placePopover(target: Box, pop: { width: number; height: number }, bounds: { width: number; height: number }, preferred: Placement) {\n  const cx = target.left + target.width / 2;\n  const cy = target.top + target.height / 2;\n  const options = {\n    bottom: { x: cx - pop.width / 2, y: target.top + target.height + OFFSET, room: bounds.height - (target.top + target.height) - OFFSET - pop.height },\n    top: { x: cx - pop.width / 2, y: target.top - OFFSET - pop.height, room: target.top - OFFSET - pop.height },\n    right: { x: target.left + target.width + OFFSET, y: cy - pop.height / 2, room: bounds.width - (target.left + target.width) - OFFSET - pop.width },\n    left: { x: target.left - OFFSET - pop.width, y: cy - pop.height / 2, room: target.left - OFFSET - pop.width },\n  };\n  const order: (keyof typeof options)[] = [\"bottom\", \"top\", \"right\", \"left\"];\n  if (preferred !== \"auto\") order.unshift(preferred);\n  const fit = order.find((side) => options[side].room >= MARGIN) ?? order.reduce((a, b) => (options[b].room > options[a].room ? b : a));\n  const choice = options[fit];\n  return {\n    x: Math.round(Math.max(MARGIN, Math.min(bounds.width - pop.width - MARGIN, choice.x))),\n    y: Math.round(Math.max(MARGIN, Math.min(bounds.height - pop.height - MARGIN, choice.y))),\n  };\n}\n\n/**\n * An onboarding tour that dims the page, cuts a spotlight around each target and anchors a step card next to\n * it. The spotlight and card spring between targets, targets are scrolled into view through their scroll\n * containers, and the card is a labelled dialog that takes focus, closes on Escape and returns focus.\n */\nexport function ProductTour({\n  steps,\n  open,\n  onOpenChange,\n  defaultStep = 0,\n  onStepChange,\n  onFinish,\n  root,\n  modal = true,\n  spotlightPadding = 8,\n  spotlightRadius = 12,\n  overlayOpacity = 0.6,\n  placement = \"auto\",\n  showProgress = true,\n  autoFocus = true,\n  spring = { stiffness: 260, damping: 30, mass: 1 },\n  accent = \"#FF7448\",\n  labels,\n  className = \"\",\n}: ProductTourProps) {\n  const uid = React.useId();\n  const maskId = `${uid.replace(/[^a-zA-Z0-9_-]/g, \"\")}-tour-mask`;\n  const titleId = `${uid}-title`;\n  const bodyId = `${uid}-body`;\n  const countId = `${uid}-count`;\n  const reduceMotion = usePrefersReducedMotion();\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const layerRef = React.useRef<HTMLDivElement>(null);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const primaryRef = React.useRef<HTMLButtonElement>(null);\n  const targetRef = React.useRef<HTMLElement | null>(null);\n  const returnFocusRef = React.useRef<HTMLElement | null>(null);\n  const animateUntilRef = React.useRef(0);\n  const shownRef = React.useRef(false);\n\n  const [step, setStep] = React.useState(defaultStep);\n  // Restart from defaultStep every time the tour opens.\n  const [wasOpen, setWasOpen] = React.useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) setStep(defaultStep);\n  }\n  const index = Math.max(0, Math.min(steps.length - 1, step));\n  const current = steps[index];\n  const last = index === steps.length - 1;\n\n  const springConfig = reduceMotion ? { stiffness: 1000, damping: 100, mass: 0.1 } : spring;\n  const sx = useSpring(0, springConfig);\n  const sy = useSpring(0, springConfig);\n  const sw = useSpring(0, springConfig);\n  const sh = useSpring(0, springConfig);\n  const px = useSpring(0, springConfig);\n  const py = useSpring(0, springConfig);\n\n  const goTo = (next: number) => {\n    const clamped = Math.max(0, Math.min(steps.length - 1, next));\n    if (clamped === index) return;\n    setStep(clamped);\n    onStepChange?.(clamped);\n  };\n  const close = () => onOpenChange(false);\n  const finish = () => {\n    onFinish?.();\n    close();\n  };\n\n  /** Measures the target and popover and moves the spotlight and card. \"jump\" skips the spring (scroll tracking). */\n  const measure = React.useCallback(\n    (mode: \"jump\" | \"animate\") => {\n      const layer = layerRef.current;\n      const pop = popoverRef.current;\n      if (!layer || !pop) return;\n      const bounds = layer.getBoundingClientRect();\n      const el = targetRef.current;\n      const pad = spotlightPadding;\n      let box: Box;\n      if (el && el.isConnected) {\n        const r = el.getBoundingClientRect();\n        box = { left: r.left - bounds.left - pad, top: r.top - bounds.top - pad, width: r.width + pad * 2, height: r.height + pad * 2 };\n      } else {\n        // No target: an empty spotlight in the middle, with the card centred.\n        box = { left: bounds.width / 2, top: bounds.height / 2, width: 0, height: 0 };\n      }\n      const size = { width: pop.offsetWidth, height: pop.offsetHeight };\n      const spot = el\n        ? placePopover(box, size, bounds, current?.placement ?? placement)\n        : { x: Math.round((bounds.width - size.width) / 2), y: Math.round((bounds.height - size.height) / 2) };\n      const apply = (value: ReturnType<typeof useSpring>, to: number) => {\n        const v = Math.round(to * 100) / 100;\n        if (mode === \"jump\") value.jump(v);\n        else value.set(v);\n      };\n      apply(sx, box.left);\n      apply(sy, box.top);\n      apply(sw, box.width);\n      apply(sh, box.height);\n      apply(px, spot.x);\n      apply(py, spot.y);\n    },\n    [current?.placement, placement, spotlightPadding, sx, sy, sw, sh, px, py],\n  );\n\n  // Open / close the native dialog (modal mode) and return focus afterwards.\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    if (open) {\n      if (!returnFocusRef.current) {\n        returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n      }\n      if (modal && dialog && !dialog.open) dialog.showModal();\n      return;\n    }\n    if (dialog?.open) dialog.close();\n    shownRef.current = false;\n    const back = returnFocusRef.current;\n    returnFocusRef.current = null;\n    if (back && back.isConnected && autoFocus) back.focus({ preventScroll: true });\n  }, [open, modal, autoFocus]);\n\n  // On every step: resolve the target, scroll it into view, then spring (or jump, on first show) to it.\n  React.useEffect(() => {\n    if (!open) return;\n    const el = resolveTarget(current?.target, root?.current ?? null);\n    targetRef.current = el;\n    const first = !shownRef.current;\n    shownRef.current = true;\n    if (el) revealTarget(el, !reduceMotion && !first, spotlightPadding + 16);\n    // While a smooth scroll settles, keep springing rather than jumping.\n    animateUntilRef.current = first || reduceMotion ? 0 : performance.now() + 900;\n    measure(first ? \"jump\" : \"animate\");\n    if (autoFocus) primaryRef.current?.focus({ preventScroll: true });\n\n    const ro = new ResizeObserver(() => measure(performance.now() < animateUntilRef.current ? \"animate\" : \"jump\"));\n    if (el) ro.observe(el);\n    if (popoverRef.current) ro.observe(popoverRef.current);\n    return () => ro.disconnect();\n  }, [open, index, current, root, reduceMotion, spotlightPadding, autoFocus, measure]);\n\n  // Track scrolling anywhere (capture) and resizes, at most once per frame.\n  React.useEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => measure(performance.now() < animateUntilRef.current ? \"animate\" : \"jump\"));\n    };\n    window.addEventListener(\"scroll\", schedule, { capture: true, passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"scroll\", schedule, { capture: true });\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, measure]);\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    if (event.key === \"Escape\" && !modal) {\n      event.preventDefault();\n      close();\n    } else if (event.key === \"ArrowRight\" && !last) {\n      goTo(index + 1);\n    } else if (event.key === \"ArrowLeft\" && index > 0) {\n      goTo(index - 1);\n    }\n  };\n\n  const text = { next: \"Next\", back: \"Back\", done: \"Done\", skip: \"Skip tour\", ...labels };\n  const fade = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: [0.16, 1, 0.3, 1] as const };\n\n  const layer = open && current && (\n    <div\n      ref={layerRef}\n      className={`${modal ? \"fixed\" : \"absolute\"} inset-0 overflow-hidden ${className}`}\n      style={{ [\"--pt-accent\" as string]: accent }}\n      onKeyDown={onKeyDown}\n    >\n      <motion.svg\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 h-full w-full\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        transition={fade}\n      >\n        <defs>\n          <mask id={maskId}>\n            <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n            <motion.rect x={sx} y={sy} width={sw} height={sh} rx={spotlightRadius} fill=\"black\" />\n          </mask>\n        </defs>\n        <rect width=\"100%\" height=\"100%\" mask={`url(#${maskId})`} className=\"fill-zinc-950\" fillOpacity={overlayOpacity} />\n        <motion.rect\n          x={sx}\n          y={sy}\n          width={sw}\n          height={sh}\n          rx={spotlightRadius}\n          fill=\"none\"\n          stroke=\"var(--pt-accent)\"\n          strokeWidth={2}\n          className={current.target ? \"\" : \"hidden\"}\n        />\n      </motion.svg>\n\n      <motion.div\n        ref={popoverRef}\n        role={modal ? undefined : \"dialog\"}\n        aria-modal={modal ? undefined : false}\n        aria-labelledby={modal ? undefined : `${countId} ${titleId}`}\n        aria-describedby={modal ? undefined : bodyId}\n        className=\"absolute left-0 top-0 w-[min(320px,calc(100%-24px))]\"\n        style={{ x: px, y: py }}\n        initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={fade}\n      >\n        <div className=\"rounded-2xl border border-zinc-900/10 bg-white p-4 text-zinc-900 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.45),0_2px_8px_rgba(0,0,0,0.06)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-[0_24px_60px_-16px_rgba(0,0,0,0.9)]\">\n          <div className=\"flex items-center justify-between gap-3\">\n            <p id={countId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              Step {index + 1} of {steps.length}\n            </p>\n            {!last && (\n              <button\n                type=\"button\"\n                onClick={close}\n                className=\"rounded-md px-1.5 py-0.5 text-[12px] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)] dark:text-zinc-400 dark:hover:text-zinc-100\"\n              >\n                {text.skip}\n              </button>\n            )}\n          </div>\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.div\n              key={index}\n              initial={reduceMotion ? false : { opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={reduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }}\n              transition={{ duration: reduceMotion ? 0 : 0.16 }}\n            >\n              <h2 id={titleId} className=\"mt-2 text-[15px] font-semibold leading-snug tracking-tight\">\n                {current.title}\n              </h2>\n              <div id={bodyId} className=\"mt-1.5 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-300\">\n                {current.body}\n              </div>\n            </motion.div>\n          </AnimatePresence>\n          <div className=\"mt-4 flex items-center justify-between gap-3\">\n            {showProgress ? (\n              <div aria-hidden=\"true\" className=\"flex items-center gap-1\">\n                {steps.map((_, i) => (\n                  <span\n                    key={i}\n                    className={`h-1.5 rounded-full transition-[width,background-color] duration-300 ${\n                      i === index ? \"w-4 bg-[var(--pt-accent)]\" : i < index ? \"w-1.5 bg-zinc-400 dark:bg-zinc-500\" : \"w-1.5 bg-zinc-900/15 dark:bg-white/15\"\n                    }`}\n                  />\n                ))}\n              </div>\n            ) : (\n              <span />\n            )}\n            <div className=\"flex items-center gap-1.5\">\n              {index > 0 && (\n                <button\n                  type=\"button\"\n                  onClick={() => goTo(index - 1)}\n                  className=\"h-8 rounded-lg border border-zinc-900/10 px-3 text-[13px] font-medium text-zinc-700 outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)] dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/[0.06]\"\n                >\n                  {text.back}\n                </button>\n              )}\n              <button\n                ref={primaryRef}\n                type=\"button\"\n                onClick={() => (last ? finish() : goTo(index + 1))}\n                className=\"h-8 rounded-lg bg-[var(--pt-accent)] px-3.5 text-[13px] font-medium text-zinc-950 outline-hidden transition-[filter] hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)]\"\n              >\n                {last ? text.done : text.next}\n              </button>\n            </div>\n          </div>\n        </div>\n        <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          Step {index + 1} of {steps.length}: {current.title}\n        </p>\n      </motion.div>\n    </div>\n  );\n\n  if (!modal) return layer || null;\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-labelledby={`${countId} ${titleId}`}\n      aria-describedby={bodyId}\n      onCancel={(event) => {\n        // Escape: keep React state as the source of truth.\n        event.preventDefault();\n        close();\n      }}\n      className=\"m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent\"\n    >\n      {layer}\n    </dialog>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/product-tour"
}
