# Product Tour

> Spotlight product tour that highlights targets step by step with keyboard control.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/product-tour
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/product-tour.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/product-tour-demo.tsx"
"use client"

import * as React from "react"
import { Bell, Plus, Search } from "lucide-react"

import { ProductTour, type TourStep } from "@/components/product-tour"

const steps: TourStep[] = [
  { target: "[data-tour=search]", title: "Find anything", body: "Search across projects, files and teammates from one place." },
  { target: "[data-tour=notifications]", title: "Stay in the loop", body: "Mentions and review requests land here first." },
  { target: "[data-tour=create]", title: "Start something new", body: "Create a project, import a repo or invite your team." },
]

export default function ProductTourDemo() {
  const [open, setOpen] = React.useState(true)

  return (
    <div className="relative h-[360px] w-full max-w-xl overflow-hidden rounded-2xl border bg-card">
      <div className="flex items-center gap-3 border-b p-3">
        <div data-tour="search" className="flex h-9 flex-1 items-center gap-2 rounded-lg border bg-background px-3 text-sm text-muted-foreground">
          <Search className="size-4" /> Search…
        </div>
        <button data-tour="notifications" type="button" aria-label="Notifications" className="flex size-9 items-center justify-center rounded-lg border bg-background">
          <Bell className="size-4" />
        </button>
      </div>
      <div className="grid gap-3 p-6">
        <div className="h-4 w-1/3 rounded bg-muted" />
        <div className="h-3 w-full rounded bg-muted/70" />
        <div className="h-3 w-4/5 rounded bg-muted/70" />
        <button
          data-tour="create"
          type="button"
          className="mt-4 inline-flex h-10 w-fit items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground"
        >
          <Plus className="size-4" /> New project
        </button>
        {!open && (
          <button type="button" onClick={() => setOpen(true)} className="w-fit text-xs font-medium underline underline-offset-4">
            Replay tour
          </button>
        )}
      </div>
      <ProductTour steps={steps} open={open} onOpenChange={setOpen} />
    </div>
  )
}
```

## Source

```tsx title="components/product-tour.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type TourStep = {
  /** CSS selector, resolved inside the tour's container */
  target: string
  title: string
  body: string
}

export interface ProductTourProps extends Omit<React.ComponentProps<"div">, "children"> {
  steps: TourStep[]
  open: boolean
  onOpenChange: (open: boolean) => void
  onFinish?: () => void
}

type Box = { top: number; left: number; width: number; height: number; rootWidth: number; rootHeight: number }

const PAD = 6
const CARD_WIDTH = 280

// Dims its positioned parent and spotlights one element at a time. Keep `steps` stable (module constant or memo).
export function ProductTour({ steps, open, onOpenChange, onFinish, className, ...props }: ProductTourProps) {
  const [index, setIndex] = React.useState(0)
  const [box, setBox] = React.useState<Box | null>(null)
  const overlay = React.useRef<HTMLDivElement | null>(null)
  const target = steps[index]?.target

  const measure = React.useCallback(
    (node: HTMLDivElement | null) => {
      const root = node?.parentElement
      const element = target ? root?.querySelector(target) : null
      if (!root || !element) return
      const a = element.getBoundingClientRect()
      const b = root.getBoundingClientRect()
      setBox({ top: a.top - b.top, left: a.left - b.left, width: a.width, height: a.height, rootWidth: b.width, rootHeight: b.height })
    },
    [target]
  )

  const attach = React.useCallback(
    (node: HTMLDivElement | null) => {
      overlay.current = node
      measure(node)
    },
    [measure]
  )

  React.useEffect(() => {
    if (!open) return
    const onResize = () => measure(overlay.current)
    window.addEventListener("resize", onResize)
    return () => window.removeEventListener("resize", onResize)
  }, [open, measure])

  if (!open || steps.length === 0) return null

  const step = steps[index]
  const last = index === steps.length - 1
  const close = () => {
    setIndex(0)
    onOpenChange(false)
  }
  const next = () => {
    if (last) {
      onFinish?.()
      close()
    } else setIndex(index + 1)
  }

  const below = box ? box.top + box.height + PAD + 12 + 190 < box.rootHeight : true
  const cardStyle: React.CSSProperties = box
    ? {
        left: Math.max(12, Math.min(box.left + box.width / 2 - CARD_WIDTH / 2, box.rootWidth - CARD_WIDTH - 12)),
        ...(below ? { top: box.top + box.height + PAD + 12 } : { bottom: box.rootHeight - box.top + PAD + 12 }),
      }
    : { left: "50%", top: "50%", transform: "translate(-50%,-50%)" }

  return (
    <div
      ref={attach}
      role="dialog"
      aria-label="Product tour"
      onKeyDown={(event) => {
        if (event.key === "Escape") close()
        else if (event.key === "ArrowRight") next()
        else if (event.key === "ArrowLeft" && index > 0) setIndex(index - 1)
      }}
      className={cn("absolute inset-0 z-40 overflow-hidden", className)}
      {...props}
    >
      {box && (
        <div
          aria-hidden
          className="absolute rounded-lg shadow-[0_0_0_9999px_rgb(0_0_0/0.55)] ring-2 ring-primary transition-all duration-300 ease-out"
          style={{ top: box.top - PAD, left: box.left - PAD, width: box.width + PAD * 2, height: box.height + PAD * 2 }}
        />
      )}
      <div
        key={index}
        style={{ ...cardStyle, width: CARD_WIDTH }}
        className="absolute animate-in rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl duration-300 fade-in"
      >
        <p className="text-xs text-muted-foreground tabular-nums">
          Step {index + 1} of {steps.length}
        </p>
        <h3 className="mt-1 text-sm font-semibold">{step.title}</h3>
        <p className="mt-1 text-sm text-muted-foreground">{step.body}</p>
        <div className="mt-4 flex items-center justify-between">
          <button type="button" onClick={close} className="text-xs text-muted-foreground hover:text-foreground focus-visible:underline focus-visible:outline-none">
            Skip tour
          </button>
          <div className="flex gap-2">
            {index > 0 && (
              <button
                type="button"
                onClick={() => setIndex(index - 1)}
                className="inline-flex h-8 items-center rounded-md border bg-background px-3 text-xs font-medium hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
              >
                Back
              </button>
            )}
            <button
              type="button"
              autoFocus
              onClick={next}
              className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none"
            >
              {last ? "Done" : "Next"}
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}
```
