# Tour

Walks a person through a screen, one element at a time, with a card beside each element and the rest dimmed.

- **Import:** `import { Tour } from "@booleanpress/ui/tour"`
- **Radix Popover:** <https://www.radix-ui.com/primitives/docs/components/popover>
- **APG Dialog (Modal):** <https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/>
- **Page:** <https://ui.booleanpress.com/components/tour> · @booleanpress/ui 0.2.0

## Usage

Give `Tour` its steps and open it from a button. Each step points at an element by a ref, a CSS selector or a function, and has a title and a description.

```tsx
import { Tour, type TourStep } from "@booleanpress/ui/tour"

export function MailersTour() {
  const [open, setOpen] = React.useState(false)
  const create = React.useRef<HTMLButtonElement>(null)
  const steps: TourStep[] = [
    { target: create, title: "Add a mailer", description: "Connect an SMTP server or an email API." },
    { target: "#delivery-log", title: "Read the log", description: "Every message, with its status.", placement: "top" },
  ]

  return (
    <>
      <Button onClick={() => setOpen(true)}>Start tour</Button>
      <Button ref={create}>New mailer</Button>
      <Tour steps={steps} open={open} onOpenChange={setOpen} />
    </>
  )
}
```

`open` and `onOpenChange` control it, or `defaultOpen` lets it control itself. The step is uncontrolled (it starts at `defaultStep`, 0, each time the tour opens) or controlled with `step` and `onStepChange`. Escape, **Skip tour** and **Finish** end it; `onFinish` tells finishing from skipping. A click elsewhere does not end it.

A step's `placement` (`bottom` by default) and `align` put the card beside its target, flipping when there is no room; the target scrolls into view first. A step without a target, or whose target is not on the page, shows its card in the middle of the window. `content` adds anything under the description.

`mask` (on by default) dims the page and cuts a spotlight round the target, 4 px out (`spotlightPadding`); the page cannot be used until the tour ends. With `mask={false}` the page stays usable and the card simply points. Run a tour once per person: store that it was seen, and offer a way to see it again.

## Examples

### Basic

Three steps over a small dashboard, with the spotlight mask.

```tsx
import * as React from "react"
import { PlusIcon, SearchIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Input } from "@booleanpress/ui/input"
import { Tour, type TourStep } from "@booleanpress/ui/tour"

export default function TourBasic() {
  const [open, setOpen] = React.useState(false)
  const create = React.useRef<HTMLButtonElement>(null)
  const search = React.useRef<HTMLDivElement>(null)
  const failures = React.useRef<HTMLDivElement>(null)

  const steps: TourStep[] = [
    {
      target: create,
      title: "Add a mailer",
      description: "Connect an SMTP server or an email API. Each mailer sends for one organisation.",
      placement: "bottom",
      align: "end",
    },
    { target: search, title: "Find a delivery", description: "Search the log by recipient, subject or message ID." },
    {
      target: failures,
      title: "Watch the failures",
      description: "Deliveries that failed in the last 24 hours. Open the log to retry them.",
      placement: "top",
    },
  ]

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-3">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Start tour
      </Button>
      <div className="flex w-full flex-col gap-4 rounded-xl border bg-card p-4">
        <div className="flex items-center justify-between gap-2">
          <h3 className="font-semibold">Mailers</h3>
          <Button ref={create} size="sm">
            <PlusIcon />
            New mailer
          </Button>
        </div>
        <div ref={search} className="relative">
          <SearchIcon className="absolute start-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
          <Input aria-label="Search deliveries" placeholder="Search deliveries" className="ps-8" />
        </div>
        <div className="grid grid-cols-2 gap-3 text-sm">
          <div className="rounded-lg border p-3">
            <p className="text-muted-foreground">Delivered today</p>
            <p className="text-xl font-semibold">1,284</p>
          </div>
          <div ref={failures} className="rounded-lg border p-3">
            <p className="text-muted-foreground">Failed</p>
            <p className="text-xl font-semibold text-destructive-strong">7</p>
          </div>
        </div>
      </div>
      <Tour steps={steps} open={open} onOpenChange={setOpen} />
    </div>
  )
}
```

### Without mask

`mask={false}`: the card points at each button and the page stays usable.

```tsx
import * as React from "react"
import { ArchiveIcon, RefreshCwIcon, SendIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Tour, type TourStep } from "@booleanpress/ui/tour"

export default function TourWithoutMask() {
  const [open, setOpen] = React.useState(false)
  const send = React.useRef<HTMLButtonElement>(null)
  const retry = React.useRef<HTMLButtonElement>(null)
  const archive = React.useRef<HTMLButtonElement>(null)

  const steps: TourStep[] = [
    { target: send, title: "Send a test", description: "Sends a test email through this mailer to your own address." },
    { target: retry, title: "Retry failures", description: "Queues every failed delivery of the last 24 hours again." },
    { target: archive, title: "Archive the log", description: "Moves deliveries older than 90 days to the archive." },
  ]

  return (
    <div className="flex flex-col items-start gap-3">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Show the toolbar
      </Button>
      <div className="flex gap-2 rounded-lg border bg-card p-2">
        <Button ref={send} variant="ghost" size="sm">
          <SendIcon />
          Send test
        </Button>
        <Button ref={retry} variant="ghost" size="sm">
          <RefreshCwIcon />
          Retry
        </Button>
        <Button ref={archive} variant="ghost" size="sm">
          <ArchiveIcon />
          Archive
        </Button>
      </div>
      <Tour steps={steps} open={open} onOpenChange={setOpen} mask={false} />
    </div>
  )
}
```

### Custom content

A first step without a target, centred, with a list; a second with keyboard keys in `content`.

```tsx
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { Kbd, KbdGroup } from "@booleanpress/ui/kbd"
import { Tour, type TourStep } from "@booleanpress/ui/tour"

export default function TourCustomContent() {
  const [open, setOpen] = React.useState(false)
  const palette = React.useRef<HTMLButtonElement>(null)

  const steps: TourStep[] = [
    {
      title: "Welcome to the delivery log",
      description: "Two things help you move around it faster.",
      content: (
        <ul className="list-disc space-y-1 ps-4 text-muted-foreground">
          <li>The command palette finds any mailer or setting.</li>
          <li>Every row opens the full delivery report.</li>
        </ul>
      ),
    },
    {
      target: palette,
      title: "Open the command palette",
      description: "Search mailers, organisations and settings from anywhere.",
      content: (
        <p className="flex items-center gap-2 text-muted-foreground">
          Press
          <KbdGroup>
            <Kbd>Ctrl</Kbd>
            <Kbd>K</Kbd>
          </KbdGroup>
        </p>
      ),
    },
  ]

  return (
    <div className="flex items-center gap-3">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Start tour
      </Button>
      <Button ref={palette} variant="secondary">
        Search…
      </Button>
      <Tour steps={steps} open={open} onOpenChange={setOpen} />
    </div>
  )
}
```

### Controlled

`step` and `onStepChange` let the page start the tour at any step and show where it is; `onFinish` marks the end.

```tsx
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { Tour, type TourStep } from "@booleanpress/ui/tour"

const SECTIONS = ["Sender", "Connection", "Limits"]

export default function TourControlled() {
  const [open, setOpen] = React.useState(false)
  const [step, setStep] = React.useState(0)
  const [finished, setFinished] = React.useState(false)
  const refs = [React.useRef<HTMLDivElement>(null), React.useRef<HTMLDivElement>(null), React.useRef<HTMLDivElement>(null)]

  const steps: TourStep[] = SECTIONS.map((name, index) => ({
    target: refs[index],
    title: name,
    description: `Step ${index + 1} of the mailer settings: what the ${name.toLowerCase()} section holds.`,
    placement: "right",
  }))

  const start = (from: number) => {
    setStep(from)
    setFinished(false)
    setOpen(true)
  }

  return (
    <div className="flex w-full max-w-md flex-col items-start gap-3">
      <div className="flex gap-2">
        <Button variant="outline" onClick={() => start(0)}>
          Start tour
        </Button>
        <Button variant="ghost" onClick={() => start(2)}>
          Start at Limits
        </Button>
      </div>
      <div className="flex w-48 flex-col gap-2">
        {SECTIONS.map((name, index) => (
          <div key={name} ref={refs[index]} className="rounded-md border bg-card px-3 py-2 text-sm">
            {name}
          </div>
        ))}
      </div>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {open ? `Showing step ${step + 1} of ${steps.length}.` : finished ? "Tour finished." : "The tour is closed."}
      </p>
      <Tour steps={steps} open={open} onOpenChange={setOpen} step={step} onStepChange={setStep} onFinish={() => setFinished(true)} />
    </div>
  )
}
```

## Accessibility

**Semantics.** The card is a `div` with `role="dialog"`, named by the step's title (an `h2`) and described by its description. With the mask it is modal: the rest of the page is hidden from assistive technology and cannot be clicked. The step count is plain text, and the mask is hidden from assistive technology.

**Labels.** Skip tour, Back, Next, Finish and the step count ("2 of 3") come from the provider's `skipTour`, `back`, `next`, `finish` and `tourStep` strings; the count's numbers follow the provider's `locale`.

**Focus.** Focus moves to the card when the tour opens and again on each new step, so a screen reader reads the title and description; Tab then reaches the buttons. With the mask, Tab stays inside the card. When the tour ends, focus returns to the element that had it before, or to `returnFocusTo` when that element is gone.

**Known limits.**

- Without the mask the page stays reachable, but a screen reader is not told which element the card points at; say it in the description.
- The target cannot be used while the mask shows. Use `mask={false}` for a step that asks the person to press it.
- A target that moves after the card opens (a layout change, not a scroll) is followed on the next scroll, resize or step.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Tab | Moves between the card's buttons; Shift+Tab goes back. With the mask it wraps within the card. |
| Enter or Space | Presses the focused button: Skip tour, Back, Next or Finish. |
| → | From the card or one of its buttons, goes to the next step (ArrowLeft on a right-to-left page). |
| ← | From the card or one of its buttons, goes back a step (ArrowRight on a right-to-left page). |
| Escape | Ends the tour and returns focus to where it was. |

## API

### Tour

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` (required) | `TourStep[]` |  | The steps, in order. |
| `arrow` | `boolean` | `true` | Draws the small arrow from the card to its target. `true` by default. |
| `className` | `string` |  | Classes for the card. |
| `defaultOpen` | `boolean` | `false` | Whether it starts showing, when it controls itself. |
| `defaultStep` | `number` | `0` | The step it opens on, when it controls its step. 0 by default; it starts there again each time it opens. |
| `mask` | `boolean` | `true` | Dims the page and cuts a spotlight round the current target; the page cannot be used while it shows. `true` by default. |
| `onFinish` | `(() => void)` |  | Called when Finish is pressed on the last step, before the tour closes. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called with `false` when Escape, Skip or Finish ends the tour. |
| `onStepChange` | `((step: number) => void)` |  | Called with the step Next or Back moves to. |
| `open` | `boolean` |  | Whether the tour is showing, when you control it. Pair it with `onOpenChange`. |
| `returnFocusTo` | `ReturnFocusTarget` |  | Where focus goes when the tour ends and the element that had focus before it is gone. |
| `spotlightPadding` | `number` | `4` | Space in px between the target and the spotlight's edge. 4 by default. |
| `step` | `number` |  | The current step, from 0, when you control it. Pair it with `onStepChange`. |

**Also exported:** `TourStep`, a TypeScript type; `TourTarget`, a TypeScript type.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="tour-content"` (Tour), and `data-state`, `data-step`.

**Provider strings:** `tourStep`, `skipTour`, `back`, `next`, `finish` (`BooleanUIProvider`'s `strings`).

## Theming

The card is the floating surface (`--popover`, `--popover-foreground`, the border token); the mask is `--mask`, as Dialog's backdrop. Motion comes from `theme.css`: the card enters as a popover, the mask as a backdrop.

| Token | Used for |
| --- | --- |
| `--heading` | text |
| `--mask` | fill |
| `--muted-foreground` | text |
| `--popover` | background |
| `--popover-foreground` | text |
