{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-tour",
  "title": "Product Tour",
  "description": "A guided tour of the real screen: a card that walks through your own UI, dimming the page and lighting up the one control each step is about, with Back and Next, a progress count, a way out, and placement that flips to whichever side of the target has room. The spotlight is re-measured every frame, so it follows a target that slides in with a panel, shifts when a web font swaps in, or gets replaced by React — and it stops lighting anything at all when the target is not there, instead of leaving a bright square over empty page. Reach for it on a first login and after a release: SaaS onboarding, a redesigned admin layout people have to be walked through, an editor's panels and shortcuts, an internal tool nobody reads the docs for, the introduction to a multi-step settings wizard, a feature announcement that has to point at the feature, a checkout or booking flow being explained to new staff, and a dashboard whose numbers need one pass of explanation. Common asks it answers: \"product tour react\", \"onboarding tour component\", \"guided tour react\", \"coachmark react\", \"spotlight onboarding overlay\", \"walkthrough component shadcn\", \"shadcn product tour\", \"driver.js alternative react\", \"react-joyride alternative\", \"intro.js for react\", \"shepherd.js react\", \"feature tour with tooltips\", \"highlight an element and explain it\", \"dim the page except one element\", \"tour that waits for a lazy-loaded element\", \"tour step that lets the user click the highlighted button\", \"resume a product tour where the user left off\", \"accessible product tour keyboard\", \"onboarding tour without floating-ui\". Official shadcn/ui has nothing for this: coachmark, spotlight, walkthrough, driver.js, tourStep, onboardingStep, highlightTarget and the word tour itself are each zero hits across all 62 of its fetchable items (measured 2026-09-26), and so are getBoundingClientRect, IntersectionObserver, ResizeObserver and clip-path — nothing in it measures the page at all. Its popover is a card tied to one element with no order, no progress and no Next; its dialog takes over the screen rather than pointing at a part of it; and its tooltip is for a label, not for a paragraph with buttons in it. Distinct from the pulld pieces it sits beside: step-indicator draws progress and points at nothing, keyboard-shortcuts and command-palette list what is possible rather than showing it in place, empty-state is what a screen says before there is anything to tour, announcement-bar tells everyone at once without pointing, and highlight-text marks words inside a string. One file, no floating-ui or popper, no portal, no react-dom import. Six things separate it from the version built out of a popover and an array index. First, the target does not hold still: a tour usually begins by opening something, and a panel sliding open with a CSS transition moves its target through a hundred positions while firing no event at all — so the rectangle is taken every frame rather than once per step, and React is only told when a number actually changed. Second, absent and gone and invisible are one state: an element that has left the document still answers getBoundingClientRect with the rectangle it had on the way out, and one inside display:none answers all zeros, which draws a spotlight in the top-left corner of the page — both count as nothing to point at, the same as a step whose code-split panel has not loaded yet, and that step degrades to a centred card and reports itself after a wait instead of pointing at a hole. Third, the hole is four rectangles, not a trick: a box-shadow spread is not a hit area, so the page around the target stays clickable while the target alone is blocked, i.e. exactly backwards, and a clip-path makes clickability a property of a path string; four elements tile the viewport exactly (no seam, no overlap, no gap to click through), and the target is made inert again by one more rectangle over the hole. Fourth, aria-modal is a lie on an interactive step: a step that has just told someone to press the highlighted button cannot also tell a screen reader that everything outside the card does not exist, so the attribute goes when the hole is live. Fifth, the same for the keyboard: the tab scope is the card alone on an inert step and the target's controls plus the card's on a live one, target first, because the thing a person was just asked to click should not be several stops past a button called Next. Sixth, placement and resume: a requested side is abandoned when the card does not fit there (otherwise Next ends up off the bottom of a laptop window), the cross axis is clamped instead of blindly centred, and where somebody stopped is reported to you to store rather than written to localStorage — a per-device flag runs the tour again on the phone — with a saved step id that no longer exists starting the tour over rather than showing a blank screen.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/product-tour.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Measures before paint so the card never appears at the centre of the screen for one frame before\n// jumping to its target. Falls back to useEffect on the server, where useLayoutEffect warns.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/** Which side of its target a step's card sits on. `\"auto\"` picks the side with room. */\nexport type TourPlacement = \"auto\" | \"top\" | \"bottom\" | \"left\" | \"right\"\n\n/**\n * What a step points at.\n *\n * A selector is the usual form because it survives the element being replaced — which is the normal\n * life of a React node, and the reason a tour cannot hold on to the element itself across a step.\n * An `Element` is accepted for the case where the page already has a ref to it, and a function for\n * the case where finding it is more than a selector (the third row of a table, the active tab's\n * panel).\n *\n * Whichever form is used, it is resolved again on every frame the tour is open rather than once per\n * step: see `ProductTour`.\n */\nexport type TourTarget = string | Element | (() => Element | null | undefined)\n\n/** One stop on the tour. */\nexport interface TourStep {\n  /**\n   * Stable identity, used as the value passed to `onStepChange` and stored for resume.\n   *\n   * It should outlive a copy edit — `\"invite-teammates\"` rather than `\"step-3\"`, which stops meaning\n   * the same thing the moment a step is inserted before it.\n   */\n  id: string\n  /**\n   * The element this step is about. Leave it out for a step that is about the product rather than a\n   * control — the welcome card, the closing card — and the step shows centred with no spotlight.\n   */\n  target?: TourTarget\n  /** Heading of the card. Becomes its accessible name. */\n  title?: string\n  /** Body of the card. */\n  content?: React.ReactNode\n  /** Preferred side. Defaults to `\"auto\"`. */\n  placement?: TourPlacement\n  /**\n   * Whether the target stays usable while this step is showing. Off by default.\n   *\n   * On, the spotlight is a real hole: clicks reach the target and `Tab` walks its controls before\n   * the card's. Off, the target is lit but inert, which is right for \"this is where your usage\n   * appears\" and wrong for \"open this menu\" — a step that asks for an action the overlay is\n   * swallowing is the one bug in a tour that makes a person abandon it.\n   *\n   * It also decides `aria-modal` on the card: see the render below.\n   */\n  interactive?: boolean\n  /** Breathing room between the target and the edge of the spotlight, in px. Overrides the tour's. */\n  padding?: number\n}\n\n/** A rectangle in viewport coordinates, as `getBoundingClientRect` gives them. */\nexport interface TourRect {\n  top: number\n  left: number\n  width: number\n  height: number\n}\n\ninterface Size {\n  width: number\n  height: number\n}\n\n/** The part of a key event this needs — satisfied by a DOM `KeyboardEvent` as much as a React one. */\ninterface KeyLike {\n  key: string\n  shiftKey?: boolean\n  preventDefault: () => void\n}\n\nconst clamp = (value: number, min: number, max: number) =>\n  max < min ? min : value < min ? min : value > max ? max : value\n\n/**\n * The overlay, as rectangles: the lit hole, and the dimmed pieces around it.\n *\n * Four rectangles rather than one with a hole cut in it, and the reason is hit-testing rather than\n * looks. The two shortcuts are a `box-shadow: 0 0 0 9999px` spread — where the shadow is not a hit\n * area at all, so the page around the target stays fully clickable while the target itself is\n * blocked, i.e. exactly backwards — and a `clip-path` polygon, which does hit-test as drawn but\n * makes \"is this region clickable\" a property of a path string rather than of an element. With four\n * rectangles the answer is an element each: they take the clicks, the gap between them does not\n * exist to take anything, and making the target inert again is one more rectangle over the hole\n * (which is what a non-interactive step renders).\n *\n * The pieces tile the viewport exactly — no seam, and no overlap, which would show as a darker band\n * where two translucent rectangles meet. That holds while the target hangs off the edge of the\n * screen too, because the hole is intersected with the viewport before the surround is built from\n * it.\n *\n * `null` for the hole means there is nothing to light up — no target, a target that has left the\n * document, one that is `display: none` (a zero-sized rect), or one scrolled entirely out of view —\n * and then the whole viewport is dimmed. Treating those as \"no hole\" rather than as a rectangle is\n * what keeps the tour from lighting up the top-left corner, which is where a zero rect sits, or the\n * place the target used to be.\n */\nexport function spotlight(\n  target: TourRect | null,\n  viewport: Size,\n  padding = 6\n): { hole: TourRect | null; dim: TourRect[] } {\n  const full: TourRect = { top: 0, left: 0, width: viewport.width, height: viewport.height }\n  if (!target || target.width <= 0 || target.height <= 0) return { hole: null, dim: [full] }\n\n  const top = clamp(target.top - padding, 0, viewport.height)\n  const bottom = clamp(target.top + target.height + padding, 0, viewport.height)\n  const left = clamp(target.left - padding, 0, viewport.width)\n  const right = clamp(target.left + target.width + padding, 0, viewport.width)\n  if (bottom <= top || right <= left) return { hole: null, dim: [full] }\n\n  const hole: TourRect = { top, left, width: right - left, height: bottom - top }\n  const dim = [\n    { top: 0, left: 0, width: viewport.width, height: top },\n    { top: bottom, left: 0, width: viewport.width, height: viewport.height - bottom },\n    { top, left: 0, width: left, height: hole.height },\n    { top, left: right, width: viewport.width - right, height: hole.height },\n  ].filter((rect) => rect.width > 0 && rect.height > 0)\n  return { hole, dim }\n}\n\nconst SIDES: ReadonlyArray<Exclude<TourPlacement, \"auto\">> = [\"bottom\", \"top\", \"right\", \"left\"]\n\n/** Room between the hole and the edge of the viewport, on each side. */\nfunction roomAround(hole: TourRect, viewport: Size, gap: number, margin: number) {\n  return {\n    top: hole.top - gap - margin,\n    bottom: viewport.height - (hole.top + hole.height) - gap - margin,\n    left: hole.left - gap - margin,\n    right: viewport.width - (hole.left + hole.width) - gap - margin,\n  }\n}\n\n/**\n * Where the card goes, given the hole it belongs to and how big the card turned out to be.\n *\n * Pure and exported, because placement is the part of a tour that is wrong in a way nobody notices\n * until a laptop or a phone is involved: the side with room on the designer's monitor is the side\n * with none on a 13-inch one, and a card that lands half off the bottom of the window has no \"Next\"\n * button on it. Three rules, in this order:\n *\n *  1. A requested side is honoured when the card fits there, and abandoned when it does not — a\n *     `placement: \"right\"` obeyed on a narrow window puts the card off the screen. The fallback is\n *     not the opposite side but the side with the most room, so a target near the top of a short\n *     window gets its card below rather than clipped above.\n *  2. The cross axis is clamped into the viewport, never centred blindly. Centring a 320px card on a\n *     target 40px from the left edge hangs 120px of it outside the window.\n *  3. When nothing fits — a target nearly as large as the window, which is what a \"this whole panel\"\n *     step is on a phone — the card is placed inside the viewport anyway and allowed to overlap the\n *     target. Being readable beats being adjacent, and the arrow is dropped in that case so it never\n *     points at something the card is sitting on top of.\n *\n * With no hole, the card is centred and there is nothing to point at.\n */\nexport function placeBubble({\n  hole,\n  bubble,\n  viewport,\n  placement = \"auto\",\n  gap = 10,\n  margin = 8,\n}: {\n  hole: TourRect | null\n  bubble: Size\n  viewport: Size\n  placement?: TourPlacement\n  gap?: number\n  margin?: number\n}): {\n  placement: Exclude<TourPlacement, \"auto\"> | \"center\"\n  left: number\n  top: number\n  /** Offset of the arrow along the card's cross axis, or `null` when there is nothing to point at. */\n  arrow: number | null\n} {\n  const maxLeft = viewport.width - bubble.width - margin\n  const maxTop = viewport.height - bubble.height - margin\n\n  if (!hole) {\n    return {\n      placement: \"center\",\n      left: clamp((viewport.width - bubble.width) / 2, margin, maxLeft),\n      top: clamp((viewport.height - bubble.height) / 2, margin, maxTop),\n      arrow: null,\n    }\n  }\n\n  const room = roomAround(hole, viewport, gap, margin)\n  const needed = (side: Exclude<TourPlacement, \"auto\">) =>\n    side === \"top\" || side === \"bottom\" ? bubble.height : bubble.width\n\n  let chosen: Exclude<TourPlacement, \"auto\"> | null = null\n  if (placement !== \"auto\" && room[placement] >= needed(placement)) chosen = placement\n  if (!chosen) chosen = SIDES.find((candidate) => room[candidate] >= needed(candidate)) ?? null\n  const fits = chosen !== null\n  // Nothing fits: the side with the most room is the one the card overlaps the target least on.\n  const side =\n    chosen ?? SIDES.reduce((best, candidate) => (room[candidate] > room[best] ? candidate : best))\n\n  const vertical = side === \"top\" || side === \"bottom\"\n  const left = vertical\n    ? clamp(hole.left + hole.width / 2 - bubble.width / 2, margin, maxLeft)\n    : clamp(\n        side === \"left\" ? hole.left - gap - bubble.width : hole.left + hole.width + gap,\n        margin,\n        maxLeft\n      )\n  const top = vertical\n    ? clamp(\n        side === \"top\" ? hole.top - gap - bubble.height : hole.top + hole.height + gap,\n        margin,\n        maxTop\n      )\n    : clamp(hole.top + hole.height / 2 - bubble.height / 2, margin, maxTop)\n\n  // The arrow tracks the centre of the target, held far enough from the card's own corners that it\n  // never grows out of a rounded one. Dropped when the card had to overlap the target, and when\n  // clamping left the card no longer reaching the side it was placed on.\n  const ARROW_INSET = 16\n  const adjacent =\n    side === \"bottom\"\n      ? top >= hole.top + hole.height\n      : side === \"top\"\n        ? top + bubble.height <= hole.top\n        : side === \"right\"\n          ? left >= hole.left + hole.width\n          : left + bubble.width <= hole.left\n  const span = vertical ? bubble.width : bubble.height\n  const arrow =\n    fits && adjacent && span >= ARROW_INSET * 2\n      ? clamp(\n          vertical ? hole.left + hole.width / 2 - left : hole.top + hole.height / 2 - top,\n          ARROW_INSET,\n          span - ARROW_INSET\n        )\n      : null\n\n  return { placement: side, left, top, arrow }\n}\n\n// Everything a browser will stop on when Tab is pressed, before visibility is taken into account.\nconst TABBABLE = [\n  \"a[href]\",\n  \"area[href]\",\n  \"button:not([disabled])\",\n  'input:not([disabled]):not([type=\"hidden\"])',\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"iframe\",\n  \"audio[controls]\",\n  \"video[controls]\",\n  '[contenteditable]:not([contenteditable=\"false\"])',\n  '[tabindex]:not([tabindex^=\"-\"])',\n].join(\",\")\n\n/** The tab stops inside `root` — plus `root` itself if it is one — skipping what a browser skips. */\nexport function tabbablesIn(root: Element | null): HTMLElement[] {\n  if (!root || typeof root.querySelectorAll !== \"function\") return []\n  const found = Array.from(root.querySelectorAll(TABBABLE)) as HTMLElement[]\n  // A target can be the control rather than contain one: the step points at a single button, and a\n  // scope built only from descendants would leave the person nothing to tab to.\n  if (root.matches?.(TABBABLE)) found.unshift(root as HTMLElement)\n  return found.filter(\n    (node) =>\n      !node.hasAttribute(\"inert\") &&\n      node.getAttribute(\"aria-hidden\") !== \"true\" &&\n      // The cheap stand-in for \"is it rendered\": a node in a `display: none` subtree, or inside a\n      // closed `details`, has no boxes at all. More honest than reading computed styles up the tree,\n      // and it is what the browser's own tab order agrees with.\n      (typeof node.getClientRects !== \"function\" || node.getClientRects().length > 0)\n  )\n}\n\n/**\n * The element a step is about, right now, or null.\n *\n * A node React has already unmounted still answers `getBoundingClientRect` — with the rectangle it\n * had when it was detached. Lighting that up is the \"spotlight over nothing\" bug, so a target that\n * is no longer in the document is no target.\n */\nfunction resolveTarget(target: TourTarget | undefined): Element | null {\n  if (!target) return null\n  if (typeof target === \"string\") {\n    if (typeof document === \"undefined\") return null\n    return document.querySelector(target)\n  }\n  const element = typeof target === \"function\" ? target() : target\n  return element && element.isConnected !== false ? element : null\n}\n\n/**\n * The rectangle a target occupies right now, or null when it has none worth pointing at.\n *\n * One rule, in one place, because \"missing\" has to mean the same thing everywhere it is asked. An\n * element that is in the document and has no box — inside a `display: none` subtree, in a closed\n * `details`, a collapsed sidebar item — is not something a tour can point at, so it counts as missing\n * exactly like an absent one. Splitting that decision across a `getBoundingClientRect` guard in one\n * place and a `!== null` check on the element in another is how a tour ends up lighting nothing while\n * insisting the target is present.\n */\nfunction boxOf(element: Element | null): TourRect | null {\n  const rect = element?.getBoundingClientRect?.()\n  if (!rect || rect.width <= 0 || rect.height <= 0) return null\n  return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }\n}\n\nconst sameRect = (a: TourRect | null, b: TourRect | null) =>\n  a === b ||\n  (a !== null &&\n    b !== null &&\n    a.top === b.top &&\n    a.left === b.left &&\n    a.width === b.width &&\n    a.height === b.height)\n\n/** Whether a rectangle is wholly inside the viewport. */\nconst fullyVisible = (rect: TourRect, viewport: Size) =>\n  rect.top >= 0 &&\n  rect.left >= 0 &&\n  rect.top + rect.height <= viewport.height &&\n  rect.left + rect.width <= viewport.width\n\n/** Why the tour ended, and where the person was when it did. */\nexport interface TourFinish {\n  /** `\"completed\"` when the last step's button was pressed; `\"dismissed\"` for Escape or the ×. */\n  reason: \"completed\" | \"dismissed\"\n  /** The step showing at the time — what resume should be stored as. */\n  stepId: string\n  /** Its index in `steps`. */\n  index: number\n}\n\nexport interface ProductTourProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"role\"> {\n  /** The stops, in order. */\n  steps: readonly TourStep[]\n  /** Controlled visibility. */\n  open?: boolean\n  /** Starting visibility for an uncontrolled tour. Ignored once `open` is passed. */\n  defaultOpen?: boolean\n  /** Called when the tour opens or closes. Pair it with `open`. */\n  onOpenChange?: (open: boolean) => void\n  /** Controlled current step, by `id`. */\n  stepId?: string\n  /**\n   * The step an uncontrolled tour starts on — i.e. resume.\n   *\n   * An id that is not in `steps` starts at the beginning rather than showing nothing, because the\n   * one way that happens in production is a position saved before the tour was rewritten, and a\n   * person coming back to a rewritten tour should see it rather than see nothing.\n   */\n  defaultStepId?: string\n  /** Called with the step now showing. */\n  onStepChange?: (stepId: string, index: number) => void\n  /**\n   * Called once when the tour ends, with why and where.\n   *\n   * This is the whole of the persistence boundary. Nothing here writes to `localStorage`: which\n   * store a \"seen it\" flag belongs in is the application's decision and usually the server's — a\n   * flag in browser storage is per-device, so the tour runs again on the phone, it is wiped with a\n   * cleared cache, and on a shared machine it belongs to whoever logged in first. Keep\n   * `finish.stepId` and hand it back as `defaultStepId` to resume.\n   */\n  onFinish?: (finish: TourFinish) => void\n  /** Room between a target and the edge of its spotlight, in px. A step can override it. */\n  padding?: number\n  /** Distance from the spotlight to the card, in px. */\n  gap?: number\n  /** Smallest distance the card keeps from the edge of the window, in px. */\n  viewportMargin?: number\n  /** Whether a target out of view is scrolled into view when its step opens. */\n  scrollTargetIntoView?: boolean\n  /**\n   * How long a step's target may be missing before `onTargetMissing` is called, in ms.\n   *\n   * A target is routinely absent for a moment — the panel it lives in is code-split, the route is\n   * still resolving, the list is still fetching — so the tour waits rather than reporting. The timer\n   * is what separates that from a step pointing at something which is never coming, usually because\n   * a selector went stale in a refactor.\n   */\n  targetTimeout?: number\n  /**\n   * Called once per step when its target has still not appeared after `targetTimeout`.\n   *\n   * The card is showing centred by then, which is legible but is no longer a tour. What to do about\n   * it belongs to the application, the only party that knows whether the step is skippable: advance\n   * past it, end the tour, or leave it and log that the selector needs fixing.\n   */\n  onTargetMissing?: (step: TourStep, index: number) => void\n  /** Accessible name of the card for a step with no `title`. */\n  label?: string\n  /** The \"2 of 5\" line. Return an empty string to leave it out. */\n  progressLabel?: (index: number, total: number) => string\n  /** Text of the button that goes back a step. */\n  backLabel?: string\n  /** Text of the button that goes on a step. */\n  nextLabel?: string\n  /** Text of the button that ends the tour on the last step. */\n  finishLabel?: string\n  /** Accessible name of the × that abandons the tour. */\n  skipLabel?: string\n  /** Whether Escape abandons the tour. */\n  closeOnEscape?: boolean\n  /** Classes for the dimmed surround. */\n  scrimClassName?: string\n}\n\nconst defaultProgressLabel = (index: number, total: number) => `${index + 1} of ${total}`\n\n/**\n * A guided tour: a card that walks through the real screen, lighting up the control it is talking\n * about.\n *\n * ```tsx\n * const [open, setOpen] = React.useState(!user.seenTour)\n *\n * <ProductTour\n *   open={open}\n *   onOpenChange={setOpen}\n *   defaultStepId={user.tourStepId ?? undefined}\n *   onStepChange={(id) => saveProgress(id)}\n *   onFinish={({ reason, stepId }) => saveSeen({ reason, stepId })}\n *   steps={[\n *     { id: \"welcome\", title: \"Welcome\", content: \"Here is the short version.\" },\n *     { id: \"search\", target: \"[data-tour='search']\", title: \"Find anything\", content: \"…\" },\n *     { id: \"new\", target: \"#new-project\", title: \"Start something\", content: \"…\", interactive: true },\n *   ]}\n * />\n * ```\n *\n * Mount it near the root of the app. The overlay is `position: fixed`, which is measured against the\n * nearest ancestor carrying a `transform`, `filter` or `perspective` rather than against the window —\n * inside one of those the tour would be trapped in a corner of the page. There is no portal because\n * an item in this registry is one file with no `react-dom` import, and the root is where a tour\n * belongs anyway.\n *\n * What it does not own is the decision to run: whether this person has seen the tour, and where they\n * left off, are the application's to store (see `onFinish`).\n */\nexport const ProductTour = React.forwardRef<HTMLDivElement, ProductTourProps>(function ProductTour(\n  {\n    className,\n    steps,\n    open: openProp,\n    defaultOpen = false,\n    onOpenChange,\n    stepId: stepIdProp,\n    defaultStepId,\n    onStepChange,\n    onFinish,\n    padding = 6,\n    gap = 10,\n    viewportMargin = 8,\n    scrollTargetIntoView = true,\n    targetTimeout = 4000,\n    onTargetMissing,\n    label = \"Product tour\",\n    progressLabel = defaultProgressLabel,\n    backLabel = \"Back\",\n    nextLabel = \"Next\",\n    finishLabel = \"Done\",\n    skipLabel = \"Skip the tour\",\n    closeOnEscape = true,\n    scrimClassName,\n    ...props\n  },\n  ref\n) {\n  const total = steps.length\n  const indexOfId = (id: string | undefined) => {\n    if (id === undefined) return 0\n    const at = steps.findIndex((candidate) => candidate.id === id)\n    return at === -1 ? 0 : at\n  }\n\n  const isOpenControlled = openProp !== undefined\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n  const open = (isOpenControlled ? openProp : uncontrolledOpen) && total > 0\n\n  const isStepControlled = stepIdProp !== undefined\n  const [uncontrolledIndex, setUncontrolledIndex] = React.useState(() => indexOfId(defaultStepId))\n  const index = clamp(\n    isStepControlled ? indexOfId(stepIdProp) : uncontrolledIndex,\n    0,\n    Math.max(total - 1, 0)\n  )\n  const step: TourStep | undefined = steps[index]\n\n  /**\n   * Rewinds an uncontrolled tour to its resume point each time it opens.\n   *\n   * Without this, a tour dismissed on step 4 and opened again starts on step 4 even though the\n   * caller asked for `defaultStepId` — last run's state is still sitting there. Written as an\n   * adjustment during render rather than in an effect so the first painted step is the right one.\n   */\n  const [lastOpen, setLastOpen] = React.useState(open)\n  if (open !== lastOpen) {\n    setLastOpen(open)\n    if (open && !isStepControlled) setUncontrolledIndex(indexOfId(defaultStepId))\n  }\n\n  const [targetRect, setTargetRect] = React.useState<TourRect | null>(null)\n  const [cardSize, setCardSize] = React.useState<Size>({ width: 320, height: 160 })\n  const [viewport, setViewport] = React.useState<Size>({ width: 0, height: 0 })\n\n  const cardRef = React.useRef<HTMLDivElement>(null)\n  const targetNode = React.useRef<Element | null>(null)\n  const scrolledFor = React.useRef<number | null>(null)\n  React.useImperativeHandle(ref, () => cardRef.current as HTMLDivElement)\n\n  const hasRect = targetRect !== null\n\n  /**\n   * Keeps the spotlight on the target, every frame, for as long as the tour is open.\n   *\n   * The alternative — measure once per step, then again on `scroll` and `resize` — is what makes\n   * tours point at empty space, and the cases it misses are not exotic. A panel that slides open\n   * with a CSS transition moves the target through a hundred positions and fires no event at all. A\n   * web font swapping in reflows the line the target sits on. An image finishing its download pushes\n   * it down the page. A sibling collapsing pulls it up. Observers cover some of that and not the\n   * animation, which is the case a tour meets first, because a tour usually begins by opening\n   * something.\n   *\n   * So the loop is the design rather than a fallback, and it is cheap: one `getBoundingClientRect`\n   * on the target, one on the card, and a comparison. React is only told when a number actually\n   * changed, so a still page settles into a loop that renders nothing. It runs while a tour is on\n   * screen and no longer.\n   *\n   * The target is resolved inside the loop as well, not captured when the step began. That is what\n   * makes \"not there yet\" and \"gone\" the same state as \"no target\" — the hole is simply not drawn —\n   * instead of a rectangle left over from something that has moved or been unmounted.\n   */\n  useIsomorphicLayoutEffect(() => {\n    if (!open || !step || typeof document === \"undefined\") return\n\n    let live = true\n    let frame = 0\n\n    const measure = () => {\n      if (!live) return\n      const element = resolveTarget(step.target)\n      targetNode.current = element\n      setTargetRect((previous) => {\n        const next = boxOf(element)\n        return sameRect(previous, next) ? previous : next\n      })\n\n      const card = cardRef.current?.getBoundingClientRect?.()\n      if (card && card.width > 0) {\n        setCardSize((previous) =>\n          previous.width === card.width && previous.height === card.height\n            ? previous\n            : { width: card.width, height: card.height }\n        )\n      }\n\n      const width = window.innerWidth\n      const height = window.innerHeight\n      setViewport((previous) =>\n        previous.width === width && previous.height === height ? previous : { width, height }\n      )\n\n      if (typeof requestAnimationFrame === \"function\") frame = requestAnimationFrame(measure)\n    }\n\n    measure()\n    return () => {\n      live = false\n      if (frame && typeof cancelAnimationFrame === \"function\") cancelAnimationFrame(frame)\n    }\n    // `step.target` and not `step`: the steps are written as an inline array, so the step object is\n    // a new one on every render of the host page, and depending on it would tear the loop down and\n    // build it up again each time. What the loop reads is the target, and `index` covers the rest.\n  }, [open, index, step?.target])\n\n  /**\n   * Gives focus back to whatever had it when the tour opened.\n   *\n   * The card takes focus on every step and is gone from the document the moment the tour ends, so\n   * without this the browser falls back to `document.body`: a person who was working the page with\n   * a keyboard — and who may not have asked for the tour at all — is returned to the top of it with\n   * their place lost. The same pattern as command-palette and keyboard-shortcuts, which is where a\n   * reader will look for it.\n   *\n   * Separate from the per-step effect below, and keyed only on `open`, because that one re-runs at\n   * every step: restoring from its cleanup would snatch focus back to the page between steps.\n   */\n  const restoreFocusTo = React.useRef<HTMLElement | null>(null)\n  React.useEffect(() => {\n    if (!open || typeof document === \"undefined\") return\n    restoreFocusTo.current = document.activeElement as HTMLElement | null\n    return () => {\n      const previous = restoreFocusTo.current\n      restoreFocusTo.current = null\n      // Only if it is still in the document. A step that told the person to press the control it\n      // was pointing at has every chance of having caused that control to be replaced, and focusing\n      // a detached node puts focus on the body — which is the thing this exists to avoid.\n      if (previous?.isConnected) previous.focus?.()\n    }\n  }, [open])\n\n  /**\n   * Moves focus onto the card at every step.\n   *\n   * To the card and not to a button on it, so a screen reader reads the step before its controls;\n   * the card is `tabIndex={-1}` for exactly this. It has to happen on every step, because the card\n   * is one element whose contents change, and content quietly replacing itself tells a screen reader\n   * nothing.\n   *\n   * Keyed on the index rather than on the step object for the same reason as the loop above:\n   * otherwise a re-render of the host page would yank focus back off whatever the person had tabbed\n   * to.\n   */\n  React.useEffect(() => {\n    if (!open) return\n    cardRef.current?.focus?.()\n  }, [open, index])\n\n  /**\n   * Brings the target into view, once per step, when it is not already.\n   *\n   * Once per step, not once per frame: re-running it would fight the person's own scrolling, and on\n   * an interactive step they may well have to scroll to use the target. Waiting for a rectangle\n   * rather than acting at the step change is what makes it work for a target that arrives late,\n   * which is the ordinary case for a code-split panel.\n   */\n  React.useEffect(() => {\n    if (!open || !scrollTargetIntoView) return\n    if (!targetRect || scrolledFor.current === index) return\n    scrolledFor.current = index\n    if (fullyVisible(targetRect, viewport)) return\n    const reduced =\n      typeof window !== \"undefined\" &&\n      typeof window.matchMedia === \"function\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    targetNode.current?.scrollIntoView?.({\n      block: \"center\",\n      inline: \"nearest\",\n      behavior: reduced ? \"auto\" : \"smooth\",\n    })\n  }, [open, index, targetRect, viewport, scrollTargetIntoView])\n\n  React.useEffect(() => {\n    if (!open) scrolledFor.current = null\n  }, [open])\n\n  /**\n   * Reports a target that is never coming, once per step.\n   *\n   * Restarted by the step change and cancelled the moment a rectangle arrives, so a step that\n   * resolves inside the window says nothing — which is the common case, and the reason this is a\n   * timer rather than a check.\n   */\n  React.useEffect(() => {\n    if (!open || !step?.target || !onTargetMissing || hasRect) return\n    if (typeof window === \"undefined\") return\n    // The timer re-establishes its own premise before reporting. Its cleanup cancels it the moment a\n    // rectangle arrives, so this should never be reachable with the target present — but \"should\n    // never\" across a fast enough sequence of renders is how a tour comes to report a step that is\n    // on screen and working, and the check costs one selector lookup.\n    const timer = window.setTimeout(() => {\n      if (boxOf(resolveTarget(step.target)) === null) onTargetMissing(step, index)\n    }, targetTimeout)\n    return () => window.clearTimeout(timer)\n    // `step` is deliberately not a dependency: see the loop above. The step it reports is the one\n    // this effect was set up for, which is what the caller needs to know.\n  }, [open, index, hasRect, targetTimeout, onTargetMissing])\n\n  const setOpenState = (next: boolean) => {\n    if (!isOpenControlled) setUncontrolledOpen(next)\n    onOpenChange?.(next)\n  }\n\n  const goTo = (next: number) => {\n    const at = clamp(next, 0, total - 1)\n    if (!isStepControlled) setUncontrolledIndex(at)\n    onStepChange?.(steps[at].id, at)\n  }\n\n  const finish = (reason: TourFinish[\"reason\"]) => {\n    if (step) onFinish?.({ reason, stepId: step.id, index })\n    setOpenState(false)\n  }\n\n  const isLast = index >= total - 1\n  const handleNext = () => (isLast ? finish(\"completed\") : goTo(index + 1))\n\n  /**\n   * Keeps Tab inside what the step is about.\n   *\n   * For a step whose target is inert the scope is the card alone, which is an ordinary modal trap.\n   * For an interactive step it cannot be: the person has been told to operate the target, so the\n   * target's own controls are in scope — and they come first, ahead of Back and Next. Putting the\n   * card's buttons first instead means the thing a person has just been asked to click is several\n   * tab stops past a button called \"Next\", and the reflex of pressing Enter on the first stop skips\n   * the step they were halfway through doing.\n   *\n   * `preventDefault` on every Tab, including the ones that move within the scope, because the whole\n   * point is that the order is this one and not the document's.\n   */\n  const handleKey = (event: KeyLike) => {\n    if (event.key === \"Escape\" && closeOnEscape) {\n      event.preventDefault()\n      finish(\"dismissed\")\n      return\n    }\n    if (event.key !== \"Tab\") return\n    const stops = [\n      ...(step?.interactive ? tabbablesIn(targetNode.current) : []),\n      ...tabbablesIn(cardRef.current),\n    ]\n    if (stops.length === 0) return\n    event.preventDefault()\n    const active = typeof document === \"undefined\" ? null : (document.activeElement as HTMLElement | null)\n    const at = active ? stops.indexOf(active) : -1\n    const delta = event.shiftKey ? -1 : 1\n    // Focus on the card itself rather than on a stop — which is where every step starts it — enters\n    // the scope at its first stop going forwards and its last going backwards.\n    const to =\n      at === -1 ? (event.shiftKey ? stops.length - 1 : 0) : (at + delta + stops.length) % stops.length\n    stops[to]?.focus?.()\n  }\n\n  /**\n   * The handler, always the current one, for the document listener below to call.\n   *\n   * Written through a ref updated after every render rather than captured by the listener, so the\n   * listener can be attached once per opening while still closing over this render's steps, index and\n   * callbacks. Assigned in an effect and not during render, because a render can be thrown away.\n   */\n  const keyHandler = React.useRef(handleKey)\n  React.useEffect(() => {\n    keyHandler.current = handleKey\n  })\n\n  /**\n   * Listens on the document, which is the only place this can work.\n   *\n   * The obvious thing is `onKeyDown` on the card, and it holds together exactly until the first Tab\n   * does what it was built to do: on an interactive step focus moves to the target, the target is\n   * outside the card's subtree, and every Tab after that never reaches the handler at all — so the\n   * scope the step promised is one stop deep and the page behind takes the rest. A capture-phase\n   * listener on the document sees the key wherever focus has got to, which is what makes the scope a\n   * scope rather than a first move.\n   *\n   * Attached only while the tour is open, so a closed tour is not listening to the page's typing.\n   */\n  React.useEffect(() => {\n    if (!open || typeof document === \"undefined\") return\n    const onKeyDown = (event: KeyboardEvent) => keyHandler.current(event)\n    document.addEventListener(\"keydown\", onKeyDown, true)\n    return () => document.removeEventListener(\"keydown\", onKeyDown, true)\n  }, [open])\n\n  const headingId = React.useId()\n  const bodyId = React.useId()\n\n  // The viewport is measured in a layout effect, i.e. before the first paint but after the first\n  // render, and every position here is derived from it. Rendering the overlay before it is known puts\n  // a card in the top-left corner of the page — harmless in the browser, where the effect corrects it\n  // before anything is painted, but it is also what the server would send, and the client would then\n  // be hydrating a card that belongs somewhere else. A tour is a client-side thing; it starts once\n  // there is a window to place it in.\n  if (!open || !step || viewport.width === 0) return null\n\n  const { hole, dim } = spotlight(targetRect, viewport, step.padding ?? padding)\n  const placed = placeBubble({\n    hole,\n    bubble: cardSize,\n    viewport,\n    placement: step.placement ?? \"auto\",\n    gap,\n    margin: viewportMargin,\n  })\n  const progress = progressLabel(index, total)\n  // Only a step that has something lit can be interactive: with no hole there is nothing to reach\n  // through, and claiming otherwise would drop `aria-modal` from a card that really is modal.\n  const interactive = step.interactive === true && hole !== null\n\n  const px = (value: number) => `${Math.round(value)}px`\n  const arrowSide =\n    placed.placement === \"bottom\"\n      ? \"top\"\n      : placed.placement === \"top\"\n        ? \"bottom\"\n        : placed.placement === \"right\"\n          ? \"left\"\n          : placed.placement === \"left\"\n            ? \"right\"\n            : null\n  const arrowStyle: React.CSSProperties =\n    arrowSide === \"top\"\n      ? { top: -5, left: px((placed.arrow ?? 0) - 4), borderWidth: \"1px 0 0 1px\" }\n      : arrowSide === \"bottom\"\n        ? { bottom: -5, left: px((placed.arrow ?? 0) - 4), borderWidth: \"0 1px 1px 0\" }\n        : arrowSide === \"left\"\n          ? { left: -5, top: px((placed.arrow ?? 0) - 4), borderWidth: \"0 0 1px 1px\" }\n          : { right: -5, top: px((placed.arrow ?? 0) - 4), borderWidth: \"1px 1px 0 0\" }\n\n  return (\n    <div className=\"pointer-events-none fixed inset-0 z-50\">\n      {/*\n        The dimmed surround. Each piece takes pointer events, so the page around the spotlight is\n        not clickable; the gap between them is where the target is, and a gap intercepts nothing.\n        Clicking the dim does nothing at all rather than ending the tour — a person who has read four\n        steps and clicked slightly wide of the card has not asked to lose the rest of it.\n      */}\n      {dim.map((rect, at) => (\n        <div\n          key={at}\n          aria-hidden=\"true\"\n          /*\n            A literal black rather than a token, which is the one place in this file that is right.\n            `bg-foreground/50` reads as \"the ink colour at half strength\" and inverts with the theme,\n            so in dark mode it washes the page out *lighter* — a scrim has to darken in both. This is\n            what shadcn's own dialog overlay does, and what command-palette in this registry does.\n          */\n          className={cn(\"pointer-events-auto absolute bg-black/50\", scrimClassName)}\n          style={{\n            top: px(rect.top),\n            left: px(rect.left),\n            width: px(rect.width),\n            height: px(rect.height),\n          }}\n        />\n      ))}\n\n      {/*\n        Over the hole, for a step whose target is only being pointed at. Without it the hole is a\n        hole in the clicks as well as in the dimming, and a person can press a button the tour has\n        not reached yet — from a screen that looks like it is not accepting input.\n      */}\n      {hole && !interactive ? (\n        <div\n          aria-hidden=\"true\"\n          data-tour-blocker=\"\"\n          className=\"pointer-events-auto absolute\"\n          style={{\n            top: px(hole.top),\n            left: px(hole.left),\n            width: px(hole.width),\n            height: px(hole.height),\n          }}\n        />\n      ) : null}\n\n      {/* The ring around the target. Never takes events: on an interactive step it sits over a live hole. */}\n      {hole ? (\n        <div\n          aria-hidden=\"true\"\n          data-tour-spotlight=\"\"\n          className=\"pointer-events-none absolute rounded-md ring-2 ring-ring\"\n          style={{\n            top: px(hole.top),\n            left: px(hole.left),\n            width: px(hole.width),\n            height: px(hole.height),\n          }}\n        />\n      ) : null}\n\n      <div\n        /*\n          A new element per step, which is what makes the step change audible. Reuse one node and the\n          focus() below is a no-op whenever focus is already on the card — and that is not a corner\n          case: Safari does not focus a button when it is clicked, so a person clicking \"Next\" there\n          leaves focus on the card, and a screen reader is told nothing at all about the step that\n          replaced the one it read out. Remounting means focus always moves, and moving into a named\n          dialog is the announcement.\n        */\n        key={step.id}\n        ref={cardRef}\n        role=\"dialog\"\n        /*\n          True only while the rest of the page really is unreachable. On an interactive step it would\n          be a lie with teeth: `aria-modal=\"true\"` makes a screen reader hide everything outside this\n          card, so the control the step has just told the person to press is not in the accessibility\n          tree for them to press. The dim rectangles and the tab scope are what enforce the modality\n          claimed here.\n        */\n        aria-modal={interactive ? undefined : true}\n        aria-labelledby={step.title ? headingId : undefined}\n        aria-label={step.title ? undefined : label}\n        aria-describedby={step.content ? bodyId : undefined}\n        tabIndex={-1}\n        data-placement={placed.placement}\n        className={cn(\n          \"pointer-events-auto absolute w-[min(20rem,calc(100vw-1rem))] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg focus-visible:outline-none\",\n          className\n        )}\n        style={{ top: px(placed.top), left: px(placed.left) }}\n        {...props}\n      >\n        {placed.arrow !== null && arrowSide ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute h-2 w-2 rotate-45 border bg-popover\"\n            style={arrowStyle}\n          />\n        ) : null}\n\n        {step.title ? (\n          <h2 id={headingId} className=\"pr-8 text-sm font-semibold leading-none tracking-tight\">\n            {step.title}\n          </h2>\n        ) : null}\n        {step.content ? (\n          <div id={bodyId} className={cn(\"text-sm text-muted-foreground\", step.title && \"mt-2\")}>\n            {step.content}\n          </div>\n        ) : null}\n\n        <div className=\"mt-4 flex items-center gap-2\">\n          {progress ? (\n            <span className=\"text-xs tabular-nums text-muted-foreground\">{progress}</span>\n          ) : null}\n          <div className=\"ml-auto flex items-center gap-2\">\n            {index > 0 ? (\n              <button\n                type=\"button\"\n                onClick={() => goTo(index - 1)}\n                className=\"inline-flex h-8 items-center rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                {backLabel}\n              </button>\n            ) : null}\n            <button\n              type=\"button\"\n              onClick={handleNext}\n              className=\"inline-flex h-8 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              {isLast ? finishLabel : nextLabel}\n            </button>\n          </div>\n        </div>\n\n        {/*\n          Last in the DOM although it sits in the top-right corner, so the tab order inside the card\n          is Back, Next, Skip rather than Skip, Back, Next — the thing a person is least likely to\n          want should not be the first stop — and a screen reader reads the step and its controls\n          before being offered the way out.\n        */}\n        <button\n          type=\"button\"\n          aria-label={skipLabel}\n          onClick={() => finish(\"dismissed\")}\n          className=\"absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <X className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n        </button>\n      </div>\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
