Skip to the content

ComponentsOverlay

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"

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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
TabMoves between the card's buttons; Shift+Tab goes back. With the mask it wraps within the card.
EnterorSpacePresses 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).
EscapeEnds the tour and returns focus to where it was.

API

Tour

Tour props
PropTypeDefaultDescription
stepsrequiredTourStep[]The steps, in order.
arrowbooleantrueDraws the small arrow from the card to its target. true by default.
classNamestringClasses for the card.
defaultOpenbooleanfalseWhether it starts showing, when it controls itself.
defaultStepnumber0The step it opens on, when it controls its step. 0 by default; it starts there again each time it opens.
maskbooleantrueDims 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.
openbooleanWhether the tour is showing, when you control it. Pair it with onOpenChange.
returnFocusToReturnFocusTargetWhere focus goes when the tour ends and the element that had focus before it is gone.
spotlightPaddingnumber4Space in px between the target and the spotlight's edge. 4 by default.
stepnumberThe 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--headingtext
--maskfill
--muted-foregroundtext
--popoverbackground
--popover-foregroundtext