{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-tour",
  "type": "registry:ui",
  "title": "Product Tour",
  "description": "Coach marks whose spotlight travels between targets, with the card gliding alongside.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/product-tour.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TourTarget = string | React.RefObject<HTMLElement | null> | (() => HTMLElement | null);\n\nexport type TourStep = {\n  /** A CSS selector (resolved inside `container` when given), a ref, or a function. Leave it out for a centered step. */\n  target?: TourTarget;\n  title: React.ReactNode;\n  content?: React.ReactNode;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Space between the target and the edge of the cutout, in px. */\n  padding?: number;\n  /** Corner radius of the cutout, in px. */\n  radius?: number;\n};\n\nexport type TourFinishReason = \"completed\" | \"skipped\";\n\nexport type ProductTourProps = {\n  steps: TourStep[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  step?: number;\n  defaultStep?: number;\n  onStepChange?: (step: number) => void;\n  /** Called once when the tour ends: by Done on the last step, or by Skip / Escape before it. */\n  onFinish?: (reason: TourFinishReason) => void;\n  /** Keep the overlay and card inside this element (it must be positioned). Defaults to the viewport. */\n  container?: HTMLElement | null;\n  labels?: { next?: string; back?: string; done?: string; skip?: string };\n  className?: string;\n};\n\nfunction resolve(target: TourTarget | undefined, root: HTMLElement | null | undefined): HTMLElement | null {\n  if (!target || typeof document === \"undefined\") return null;\n  if (typeof target === \"string\") return (root ?? document).querySelector<HTMLElement>(target);\n  if (typeof target === \"function\") return target();\n  return target.current;\n}\n\nconst subscribe = () => () => {};\nconst useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);\n\n/**\n * A guided walk through the interface. One overlay stays up for the whole\n * tour and its cutout travels from target to target, so the eye is carried\n * rather than asked to find the next thing.\n */\nexport function ProductTour({\n  steps,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  step: stepProp,\n  defaultStep = 0,\n  onStepChange,\n  onFinish,\n  container,\n  labels,\n  className,\n}: ProductTourProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [rawStep, setStep] = useControllableState({ value: stepProp, defaultValue: defaultStep, onChange: onStepChange });\n  const [moving, setMoving] = useState(false);\n  const [instant, setInstant] = useState(false);\n  const moveTimer = useRef<number>(undefined);\n  const nextRef = useRef<HTMLButtonElement>(null);\n  const returnRef = useRef<HTMLElement | null>(null);\n  const hydrated = useHydrated();\n  const reduce = useReducedMotion();\n  const titleId = useId();\n\n  const count = steps.length;\n  const index = Math.min(Math.max(rawStep, 0), Math.max(count - 1, 0));\n  const current = steps[index];\n  const last = index === count - 1;\n  const text = { next: \"Next\", back: \"Back\", done: \"Done\", skip: \"Skip tour\", ...labels };\n\n  // Remember what had focus before the tour, to hand it back at the end.\n  useEffect(() => {\n    if (!open) return;\n    returnRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n  }, [open]);\n  useEffect(() => () => window.clearTimeout(moveTimer.current), []);\n\n  // Back leaves on the first step and Skip on the last. If focus was on one of them,\n  // hand it to Next instead of letting it fall to the card or the page.\n  useEffect(() => {\n    const active = document.activeElement;\n    const popup = nextRef.current?.closest(\"[role=dialog]\");\n    if (open && popup && (active === popup || active === document.body)) nextRef.current?.focus();\n  }, [index, open]);\n\n  // Bring the target into view before the cutout lands on it.\n  useEffect(() => {\n    if (!open) return;\n    const el = resolve(current?.target, container);\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const box = container?.getBoundingClientRect() ?? { top: 0, bottom: window.innerHeight };\n    if (r.top < box.top + 16 || r.bottom > box.bottom - 16) el.scrollIntoView({ block: \"center\", behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [open, index, current, container, reduce]);\n\n  // A fresh virtual anchor per step makes the positioner re-measure; its rect is\n  // read lazily from the live target, so it follows scrolling and resizing.\n  const anchor = useMemo(() => {\n    const target = current?.target;\n    return {\n      getBoundingClientRect: () => {\n        const el = resolve(target, container);\n        if (el) return el.getBoundingClientRect();\n        const box = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);\n        return new DOMRect(box.left + box.width / 2, box.top + box.height / 2, 0, 0);\n      },\n      get contextElement() {\n        return resolve(target, container) ?? container ?? undefined;\n      },\n    };\n  }, [current, container]);\n\n  const finish = (reason: TourFinishReason) => {\n    setOpen(false);\n    onFinish?.(reason);\n  };\n\n  const go = (to: number, via: \"pointer\" | \"key\") => {\n    if (to < 0) return;\n    if (to >= count) return finish(\"completed\");\n    window.clearTimeout(moveTimer.current);\n    // Arrow keys are shortcuts: the new step lands on the same frame. Buttons glide.\n    const glide = via === \"pointer\" && !reduce;\n    setInstant(!glide);\n    setMoving(glide);\n    if (glide) moveTimer.current = window.setTimeout(() => setMoving(false), 460);\n    setStep(to);\n  };\n\n  if (!hydrated || !current) return null;\n  const centered = !current.target;\n  const pad = current.padding ?? 6;\n\n  return (\n    <>\n      {createPortal(\n        <AnimatePresence>\n          {open && (\n            <Spotlight\n              key=\"spotlight\"\n              getTarget={() => resolve(current.target, container)}\n              stepKey={index}\n              instant={instant}\n              contained={!!container}\n              container={container}\n              padding={pad}\n              radius={current.radius ?? 10}\n              className={className}\n            />\n          )}\n        </AnimatePresence>,\n        container ?? document.body,\n      )}\n      <Popover.Root\n        open={open}\n        modal={false}\n        onOpenChange={(next, details) => {\n          if (next) return setOpen(true);\n          // Only an explicit choice ends the tour: Escape counts as Skip, a stray click outside does not.\n          if (details.reason === \"escape-key\") return finish(\"skipped\");\n          details.cancel();\n        }}\n      >\n        <Popover.Portal container={container ?? undefined}>\n          <Popover.Positioner\n            anchor={anchor}\n            side={centered ? \"bottom\" : (current.side ?? \"bottom\")}\n            align={current.align ?? \"center\"}\n            sideOffset={centered ? ({ positioner }) => -positioner.height / 2 : pad + 10}\n            collisionPadding={12}\n            arrowPadding={16}\n            data-moving={moving ? \"\" : undefined}\n            className={cn(\n              \"z-(--z-popover)\",\n              // While travelling between targets the card glides with the cutout; otherwise it tracks scroll exactly.\n              \"data-moving:transition-transform data-moving:duration-[420ms] data-moving:ease-in-out-quart\",\n            )}\n          >\n            <Popover.Popup\n              initialFocus={nextRef}\n              finalFocus={returnRef}\n              aria-labelledby={titleId}\n              onKeyDown={(e) => {\n                // Tab cycles inside the card: the tour holds focus until it's finished or skipped.\n                if (e.key === \"Tab\") {\n                  const items = [...e.currentTarget.querySelectorAll<HTMLElement>(\"button:not([disabled]), [href], input, [tabindex]:not([tabindex='-1'])\")];\n                  const at = items.indexOf(document.activeElement as HTMLElement);\n                  const to = e.shiftKey ? (at <= 0 ? items.length - 1 : at - 1) : at === items.length - 1 ? 0 : at + 1;\n                  if (items[to]) {\n                    e.preventDefault();\n                    items[to].focus();\n                  }\n                  return;\n                }\n                if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;\n                if (e.key === \"ArrowRight\") {\n                  e.preventDefault();\n                  go(index + 1, \"key\");\n                } else if (e.key === \"ArrowLeft\") {\n                  e.preventDefault();\n                  go(index - 1, \"key\");\n                }\n              }}\n              className={cn(\n                \"relative w-[300px] max-w-[var(--available-width)] rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n                \"origin-[var(--transform-origin)] transition-[opacity,scale,filter] duration-[240ms] ease-out-expo\",\n                \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n                \"data-instant:transition-none\",\n                \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n              )}\n            >\n              {!centered && <TourArrow moving={moving} />}\n              <AutoHeight instant={instant || !!reduce}>\n                <div className=\"p-4 pb-3.5\">\n                  <div className=\"mb-1.5 flex h-5 items-center justify-between gap-3\">\n                    <p className=\"tabular text-[11px] tracking-[0.02em] text-fg-3\">\n                      {index + 1} of {count}\n                    </p>\n                    {!last && (\n                      <button\n                        type=\"button\"\n                        onClick={() => finish(\"skipped\")}\n                        className={cn(\n                          \"relative -mr-1.5 inline-flex h-6 items-center rounded-md px-1.5 text-[12px] text-fg-3 outline-none\",\n                          \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75\",\n                          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                          \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n                        )}\n                      >\n                        {text.skip}\n                      </button>\n                    )}\n                  </div>\n                  {/* The words change with the step; the card around them stays put and resizes smoothly. */}\n                  <motion.div\n                    key={index}\n                    aria-live=\"polite\"\n                    initial={instant || reduce ? false : { opacity: 0, filter: \"blur(2px)\" }}\n                    animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                    transition={{ duration: 0.26, ease: ease.out, delay: 0.08 }}\n                  >\n                    <Popover.Title id={titleId} className=\"text-[14px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\">\n                      {current.title}\n                    </Popover.Title>\n                    {current.content && (\n                      <Popover.Description className=\"mt-1 text-[12.5px] leading-[1.5] text-fg-2 text-pretty\">{current.content}</Popover.Description>\n                    )}\n                  </motion.div>\n                  <div className=\"mt-4 flex items-center justify-between gap-3\">\n                    <Dots count={count} index={index} />\n                    <div className=\"flex items-center gap-1.5\">\n                      {index > 0 && (\n                        <button\n                          type=\"button\"\n                          onClick={() => go(index - 1, \"pointer\")} className={cn(button, \"border border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover\")}>\n                          {text.back}\n                        </button>\n                      )}\n                      <button ref={nextRef} type=\"button\" onClick={() => go(index + 1, \"pointer\")} className={cn(button, \"bg-fg text-frame hover:bg-fg/90\")}>\n                        {/* Next and Done share a cell so the button never changes width on the last step. */}\n                        <span className=\"grid\">\n                          <span aria-hidden className=\"invisible col-start-1 row-start-1\">{text.next}</span>\n                          <span aria-hidden className=\"invisible col-start-1 row-start-1\">{text.done}</span>\n                          <span className=\"col-start-1 row-start-1\">{last ? text.done : text.next}</span>\n                        </span>\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              </AutoHeight>\n            </Popover.Popup>\n          </Popover.Positioner>\n        </Popover.Portal>\n      </Popover.Root>\n    </>\n  );\n}\n\nconst button = cn(\n  \"inline-flex h-7 shrink-0 items-center justify-center rounded-md px-2.5 text-[12px] font-medium shadow-[var(--shadow)] outline-none\",\n  \"transition-[background-color,border-color,scale] duration-150 active:scale-[0.97] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n);\n\n// The active dot stretches into a pill; passed steps stay a step brighter than the ones ahead.\nfunction Dots({ count, index }: { count: number; index: number }) {\n  return (\n    <div aria-hidden className=\"flex items-center gap-1\">\n      {Array.from({ length: count }, (_, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"h-1.5 rounded-full transition-[width,background-color] duration-300 ease-in-out-quart\",\n            i === index ? \"w-4 bg-fg\" : i < index ? \"w-1.5 bg-fg-3\" : \"w-1.5 bg-fg-4\",\n          )}\n        />\n      ))}\n    </div>\n  );\n}\n\n// Animates the card's height when a step has more or fewer words than the last.\nfunction AutoHeight({ children, instant }: { children: React.ReactNode; instant: boolean }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height }}\n      transition={instant ? { duration: 0 } : { duration: 0.32, ease: ease.inOut }}\n      className=\"overflow-hidden rounded-[inherit]\"\n    >\n      <div ref={inner}>{children}</div>\n    </motion.div>\n  );\n}\n\nfunction TourArrow({ moving }: { moving: boolean }) {\n  return (\n    <Popover.Arrow\n      className={cn(\n        \"pointer-events-none z-[1] flex h-2.5 w-5\",\n        moving && \"transition-[left,top] duration-[420ms] ease-in-out-quart\",\n        \"data-[side=bottom]:-top-[9px]\",\n        \"data-[side=top]:-bottom-[9px] data-[side=top]:rotate-180\",\n        \"data-[side=left]:-right-3.5 data-[side=left]:rotate-90\",\n        \"data-[side=right]:-left-3.5 data-[side=right]:-rotate-90\",\n      )}\n    >\n      <svg width=\"20\" height=\"10\" viewBox=\"0 0 20 10\" fill=\"none\" aria-hidden className=\"block overflow-visible\">\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2z\" className=\"fill-raised\" />\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2\" className=\"stroke-line-2\" vectorEffect=\"non-scaling-stroke\" />\n      </svg>\n    </Popover.Arrow>\n  );\n}\n\ntype Box = { x: number; y: number; w: number; h: number; r: number };\n\n/**\n * The dimmed layer with a hole over the target. Its rect is re-read from the\n * live target every frame, and on a step change it blends from where it was to\n * where the new target is, so it lands correctly even if the page scrolls mid-move.\n */\nfunction Spotlight({\n  getTarget,\n  stepKey,\n  instant,\n  contained,\n  container,\n  padding,\n  radius,\n  className,\n}: {\n  getTarget: () => HTMLElement | null;\n  stepKey: number;\n  instant: boolean;\n  contained: boolean;\n  container?: HTMLElement | null;\n  padding: number;\n  radius: number;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const w = useMotionValue(0);\n  const h = useMotionValue(0);\n  const r = useMotionValue(radius);\n  const W = useMotionValue(0);\n  const H = useMotionValue(0);\n  const progress = useMotionValue(1);\n  const from = useRef<Box | null>(null);\n  const live = useRef({ getTarget, padding, radius });\n  const [pulse, setPulse] = useState(0);\n\n  useEffect(() => {\n    live.current = { getTarget, padding, radius };\n  });\n\n  useEffect(() => {\n    // The first step appears in place; later ones travel from the current cutout.\n    const first = from.current === null;\n    from.current = { x: x.get(), y: y.get(), w: w.get(), h: h.get(), r: r.get() };\n    if (first || instant || reduce) return progress.set(1);\n    progress.set(0);\n    const controls = animate(progress, 1, { duration: 0.42, ease: ease.inOut });\n    return () => controls.stop();\n  }, [stepKey, instant, reduce, progress, x, y, w, h, r]);\n\n  useEffect(() => {\n    let frame = 0;\n    const tick = () => {\n      const { getTarget, padding, radius } = live.current;\n      const box = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);\n      W.set(box.width);\n      H.set(box.height);\n      const el = getTarget();\n      const rect = el?.getBoundingClientRect();\n      const to: Box = rect\n        ? { x: rect.left - box.left - padding, y: rect.top - box.top - padding, w: rect.width + padding * 2, h: rect.height + padding * 2, r: radius }\n        : { x: box.width / 2, y: box.height / 2, w: 0, h: 0, r: 0 };\n      const f = from.current ?? to;\n      const p = progress.get();\n      const mix = (a: number, b: number) => a + (b - a) * p;\n      x.set(mix(f.x, to.x));\n      y.set(mix(f.y, to.y));\n      w.set(mix(f.w, to.w));\n      h.set(mix(f.h, to.h));\n      r.set(mix(f.r, to.r));\n      frame = requestAnimationFrame(tick);\n    };\n    tick();\n    return () => cancelAnimationFrame(frame);\n  }, [container, progress, x, y, w, h, r, W, H]);\n\n  // Outer rectangle, then the rounded hole; evenodd leaves the hole unpainted, so the target stays clickable.\n  const d = useTransform(() => {\n    const [X, Y, Wd, Ht] = [x.get(), y.get(), Math.max(w.get(), 0), Math.max(h.get(), 0)];\n    const R = Math.min(r.get(), Wd / 2, Ht / 2);\n    const outer = `M0 0H${W.get()}V${H.get()}H0Z`;\n    if (Wd === 0 || Ht === 0) return outer;\n    return `${outer}M${X + R} ${Y}H${X + Wd - R}A${R} ${R} 0 0 1 ${X + Wd} ${Y + R}V${Y + Ht - R}A${R} ${R} 0 0 1 ${X + Wd - R} ${Y + Ht}H${X + R}A${R} ${R} 0 0 1 ${X} ${Y + Ht - R}V${Y + R}A${R} ${R} 0 0 1 ${X + R} ${Y}Z`;\n  });\n  const ringW = useTransform(() => Math.max(w.get(), 0));\n  const ringH = useTransform(() => Math.max(h.get(), 0));\n\n  return (\n    <motion.svg\n      aria-hidden\n      data-tour-overlay=\"\"\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: { duration: 0.16, ease: ease.out } }}\n      transition={{ duration: reduce ? 0.12 : 0.24, ease: ease.out }}\n      className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-overlay) h-full w-full overflow-visible\", className)}\n    >\n      {/* A click on the dim layer doesn't end the tour; the ring pings to say where to look. */}\n      <motion.path d={d} fillRule=\"evenodd\" className=\"fill-overlay\" onPointerDown={() => setPulse((p) => p + 1)} />\n      <motion.rect x={x} y={y} width={ringW} height={ringH} rx={r} className=\"pointer-events-none fill-none stroke-fg/25\" strokeWidth={1} />\n      {pulse > 0 && !reduce && (\n        <motion.rect\n          key={pulse}\n          x={x}\n          y={y}\n          width={ringW}\n          height={ringH}\n          rx={r}\n          className=\"pointer-events-none fill-none stroke-fg\"\n          strokeWidth={1.5}\n          style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n          initial={{ opacity: 0.7, scale: 1 }}\n          animate={{ opacity: 0, scale: 1.14 }}\n          transition={{ duration: 0.6, ease: ease.out }}\n        />\n      )}\n    </motion.svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/product-tour"
}
