# Tour 1: guided product tour

> A guided product tour: a spotlight lifts one part of your interface out of a dimmed page while a card says what it is for, with Next, Back, arrow keys, Escape, a progress bar, focus that follows the step, and a restart.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/tour-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/tour-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/tour-1-light-6671fb6530f7951d.png
- Registry item: https://ui.ballmac.com/r/tour-1.json
- Categories: blocks, tour
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/tour-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`

## Files

### `registry/ballmac/components/blocks/tour-1/tour-1.tsx`

```tsx
// Ballmac UI: Tour 1. https://ui.ballmac.com/blocks/tour-1
"use client"

import * as React from "react"
import { ArrowLeft, ArrowRight, Bell, BarChart3, FolderKanban, Home, Inbox, Play, RotateCcw, Search, Settings, X } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { cn } from "@/lib/utils"

type Tour1Step = {
  /** The value of the data-tour attribute on the element to point at. */
  target: string
  title: string
  body: string
}

type Tour1Props = Omit<React.ComponentProps<"section">, "title" | "onChange"> & {
  steps?: Tour1Step[]
  /** The interface to tour. Mark the elements to point at with data-tour="name". Leave out to tour a built-in sample app. */
  children?: React.ReactNode
  /** Start the tour already running, at a step. */
  defaultStep?: number | null
  /** Called when the tour ends, finished or skipped. */
  onFinish?: (result: { completed: boolean; step: number }) => void
}

const sampleSteps: Tour1Step[] = [
  { target: "nav", title: "Everything starts here", body: "Move between your home, projects, inbox and reports. The one you are on is highlighted." },
  { target: "search", title: "Find anything fast", body: "Press / from anywhere to search projects, people and files. It finds words inside documents too." },
  { target: "chart", title: "See how the week went", body: "This chart shows work finished each day. Hover or focus a bar to read its exact number." },
  { target: "alerts", title: "Never miss a mention", body: "Replies and approvals land here. A dot means something is waiting for you." },
]

function SampleApp() {
  const bars = [38, 52, 44, 68, 59, 31, 74]
  return (
    <div className="bg-card grid min-h-[36rem] grid-cols-[3.5rem_minmax(0,1fr)] sm:grid-cols-[11rem_minmax(0,1fr)]">
      <nav data-tour="nav" aria-label="Sample navigation" className="bg-muted/40 border-e p-2 sm:p-3">
        <p className="mb-3 hidden px-2 text-sm font-semibold sm:block">Acme</p>
        {[[Home, "Home"], [FolderKanban, "Projects"], [Inbox, "Inbox"], [BarChart3, "Reports"], [Settings, "Settings"]].map(([Icon, label], i) => {
          const I = Icon as typeof Home
          return (
            <div key={label as string} className={cn("flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", i === 0 ? "bg-background font-medium shadow-sm" : "text-muted-foreground")}>
              <I aria-hidden="true" className="size-4 shrink-0" />
              <span className="hidden sm:inline">{label as string}</span>
            </div>
          )
        })}
      </nav>
      <div className="min-w-0 p-3 sm:p-5">
        <div className="flex items-center gap-3">
          <div data-tour="search" className="bg-background text-muted-foreground flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border px-3 text-sm">
            <Search aria-hidden="true" className="size-4 shrink-0" />
            <span className="truncate">Search projects, people, files</span>
          </div>
          <div data-tour="alerts" className="bg-background relative flex size-9 shrink-0 items-center justify-center rounded-lg border">
            <Bell aria-hidden="true" className="size-4" />
            <span aria-hidden="true" className="bg-destructive absolute end-1.5 top-1.5 size-2 rounded-full" />
          </div>
        </div>
        <div className="mt-4 grid grid-cols-3 gap-3">
          {[["Open", "24"], ["Done", "118"], ["Late", "3"]].map(([l, v]) => (
            <div key={l} className="rounded-xl border p-3">
              <p className="text-muted-foreground text-xs">{l}</p>
              <p className="text-xl font-semibold tabular-nums">{v}</p>
            </div>
          ))}
        </div>
        <div data-tour="chart" className="mt-4 rounded-xl border p-4">
          <p className="text-sm font-medium">Finished this week</p>
          <div className="mt-3 flex h-28 items-end gap-2" aria-hidden="true">
            {bars.map((h, i) => (
              <span key={i} className="bg-chart-1/70 flex-1 rounded-t-md" style={{ height: `${h}%` }} />
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

/**
 * A guided product tour: a spotlight lifts one part of your interface out of a dimmed page while a card beside it says what it is for. Next, Back, the arrow
 * keys and Escape all work, a progress bar and 'Step 2 of 4' say where you are, focus moves to the card at each step, and the tour can be started again. Mark what to
 * point at with data-tour attributes in your own interface, or leave it empty to tour the sample app.
 */
function Tour1({ steps = sampleSteps, children, defaultStep = null, onFinish, className, ...props }: Tour1Props) {
  const id = React.useId()
  const [step, setStep] = React.useState<number | null>(defaultStep)
  const [rect, setRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(null)
  const [box, setBox] = React.useState({ w: 0, h: 0 })
  const stage = React.useRef<HTMLDivElement>(null)
  const card = React.useRef<HTMLDivElement>(null)
  const startBtn = React.useRef<HTMLButtonElement>(null)
  const running = step !== null
  const s = running ? steps[step!] : undefined

  const measure = React.useCallback(() => {
    const root = stage.current
    if (!root || step === null) return
    const el = root.querySelector<HTMLElement>(`[data-tour="${steps[step]?.target}"]`)
    const r0 = root.getBoundingClientRect()
    setBox({ w: r0.width, h: r0.height })
    if (!el) return setRect(null)
    const r = el.getBoundingClientRect()
    setRect({ x: r.left - r0.left, y: r.top - r0.top, w: r.width, h: r.height })
  }, [step, steps])

  React.useLayoutEffect(() => {
    measure()
    if (!running) return
    window.addEventListener("resize", measure)
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null
    if (stage.current) ro?.observe(stage.current)
    return () => {
      window.removeEventListener("resize", measure)
      ro?.disconnect()
    }
  }, [measure, running])

  // Move focus to the card when a step appears, and back to Start when the tour ends.
  const wasRunning = React.useRef(running)
  const mounted = React.useRef(false)
  React.useEffect(() => {
    // Never take focus on page load, only when the person starts, moves or ends the tour.
    if (mounted.current) {
      if (running) card.current?.querySelector<HTMLElement>("[data-primary]")?.focus({ preventScroll: true })
      else if (wasRunning.current) startBtn.current?.focus()
    }
    mounted.current = true
    wasRunning.current = running
  }, [running, step])

  function end(completed: boolean) {
    onFinish?.({ completed, step: step ?? 0 })
    setStep(null)
  }
  const go = (d: number) => {
    if (step === null) return
    const n = step + d
    if (n >= steps.length) end(true)
    else if (n >= 0) setStep(n)
  }

  // The card sits below the target when there is room, otherwise above, and stays inside the stage.
  const CARD_W = Math.min(320, Math.max(220, box.w - 24))
  const pad = 8
  const CARD_H = 215
  const pos = (() => {
    if (!rect) return { left: 12, top: 12 }
    const below = rect.y + rect.h + pad + 6 + CARD_H < box.h
    const left = Math.max(12, Math.min(box.w - CARD_W - 12, rect.x + rect.w / 2 - CARD_W / 2))
    return { left, top: Math.max(12, Math.min(box.h - CARD_H - 12, below ? rect.y + rect.h + pad + 6 : rect.y - pad - CARD_H - 6)) }
  })()

  return (
    <section data-slot="tour-1" aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-4xl px-4 py-10 sm:px-6", className)} {...props}>
      <div className="mb-6 flex flex-wrap items-end justify-between gap-4">
        <div>
          <h2 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
            Take the tour
          </h2>
          <p className="text-muted-foreground mt-2 max-w-md text-pretty">A minute to see where things are.</p>
        </div>
        <Button ref={startBtn} onClick={() => setStep(0)} disabled={running}>
          {running ? <RotateCcw aria-hidden="true" /> : <Play aria-hidden="true" className="fill-current" />}
          {running ? "Tour in progress" : "Start the tour"}
        </Button>
      </div>

      <div
        ref={stage}
        className="relative overflow-hidden rounded-3xl border"
        onKeyDown={(e) => {
          if (!running) return
          if (e.key === "Escape") { e.stopPropagation(); end(false) }
          else if (e.key === "ArrowRight") { e.preventDefault(); go(1) }
          else if (e.key === "ArrowLeft") { e.preventDefault(); go(-1) }
        }}
      >
        <div inert={running ? true : undefined}>{children ?? <SampleApp />}</div>

        {running && s ? (
          <>
            {!rect ? <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-black/55" /> : null}
            {rect ? (
              <div aria-hidden="true" className="ring-background pointer-events-none absolute rounded-xl ring-2 transition-[left,top,width,height] duration-300 ease-out motion-reduce:transition-none" style={{ left: rect.x - 6, top: rect.y - 6, width: rect.w + 12, height: rect.h + 12, boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.55)" }} />
            ) : null}
            <div
              ref={card}
              role="dialog"
              aria-modal="false"
              aria-labelledby={`${id}-st`}
              aria-describedby={`${id}-sb`}
              className="bg-popover text-popover-foreground absolute z-10 rounded-2xl border p-5 shadow-2xl transition-[left,top] duration-300 ease-out motion-reduce:transition-none"
              style={{ width: CARD_W, left: pos.left, top: pos.top }}
            >
              <div className="flex items-start justify-between gap-3">
                <p className="text-muted-foreground text-xs font-medium tabular-nums">
                  Step {step! + 1} of {steps.length}
                </p>
                <button type="button" onClick={() => end(false)} aria-label="Skip the tour" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 rounded-md p-1 outline-none focus-visible:ring-[3px]">
                  <X aria-hidden="true" className="size-4" />
                </button>
              </div>
              <h3 id={`${id}-st`} className="mt-1.5 font-semibold tracking-tight text-balance">
                {s.title}
              </h3>
              <p id={`${id}-sb`} className="text-muted-foreground mt-1.5 text-sm text-pretty">
                {s.body}
              </p>
              <div role="progressbar" aria-label="Tour progress" aria-valuemin={1} aria-valuemax={steps.length} aria-valuenow={step! + 1} className="mt-4 flex gap-1">
                {steps.map((_, i) => (
                  <span key={i} aria-hidden="true" className={cn("h-1 flex-1 rounded-full", i <= step! ? "bg-foreground" : "bg-muted")} />
                ))}
              </div>
              <div className="mt-4 flex items-center justify-between gap-2">
                <Button variant="ghost" size="sm" onClick={() => go(-1)} disabled={step === 0}>
                  <ArrowLeft aria-hidden="true" className="rtl:rotate-180" />
                  Back
                </Button>
                <Button size="sm" data-primary onClick={() => go(1)}>
                  {step === steps.length - 1 ? "Finish" : "Next"}
                  {step === steps.length - 1 ? null : <ArrowRight aria-hidden="true" className="rtl:rotate-180" />}
                </Button>
              </div>
            </div>
          </>
        ) : null}
      </div>
      <p className="text-muted-foreground mt-3 text-xs">While the tour runs: Left and Right arrows move between steps, Escape ends it.</p>
      <p aria-live="polite" className="sr-only">
        {running && s ? `Step ${step! + 1} of ${steps.length}: ${s.title}` : ""}
      </p>
    </section>
  )
}

export { Tour1, type Tour1Props, type Tour1Step }
```
