Tour

A step-by-step product tour that spotlights real elements on the page.

@drift-ui/tourRegistry JSON
On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

API reference

Tour

Wrap the page in Tour and mark elements with TourTarget. Arrow keys move between steps and Escape ends the tour. Focus stays in the card and returns to the opener when it closes.

Prop
stepsRequiredTourStep[]Steps in order: { target, title, description?, side? }, where side is "top", "right", "bottom" or "left".
openbooleanControlled open state.
defaultOpenboolean= falseInitial open state when uncontrolled.
onOpenChange(open: boolean) => voidCalled when the tour starts or ends.
stepnumberControlled zero-based step.
defaultStepnumber= 0Initial step when uncontrolled.
onStepChange(step: number) => voidCalled when the step changes.
onComplete() => voidCalled after Finish on the last step.

TourTarget

Marks its single child as a tour target without adding an element.

Prop
nameRequiredstringMatches a step's target.

Source

components/ui/tour.tsx
"use client"

import * as React from "react"
import { XIcon } from "lucide-react"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
} from "@/components/ui/popover"
import {
  TourSpotlight,
  findTourTarget,
  useScrollTargetIntoView,
} from "@/components/ui/tour-spotlight"

type TourStep = {
  target: string
  title: string
  description?: React.ReactNode
  side?: "top" | "right" | "bottom" | "left"
}

function TourTarget({
  name,
  ...props
}: React.ComponentProps<typeof Slot.Root> & { name: string }) {
  return <Slot.Root data-tour-target={name} {...props} />
}

function TourProgress({ count, current }: { count: number; current: number }) {
  return (
    <div aria-hidden className="flex items-center gap-1">
      {Array.from({ length: count }, (_, index) => (
        <span
          key={index}
          className={cn(
            "h-1.5 rounded-full bg-muted-foreground/30 transition-all duration-300",
            index === current ? "w-4 bg-foreground" : "w-1.5"
          )}
        />
      ))}
    </div>
  )
}

function Tour({
  steps,
  open,
  defaultOpen = false,
  onOpenChange,
  step,
  defaultStep = 0,
  onStepChange,
  onComplete,
  children,
}: {
  steps: TourStep[]
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  step?: number
  defaultStep?: number
  onStepChange?: (step: number) => void
  onComplete?: () => void
  children?: React.ReactNode
}) {
  const titleId = React.useId()
  const nextButtonRef = React.useRef<HTMLButtonElement>(null)
  const returnFocusRef = React.useRef<HTMLElement | null>(null)
  const { value: isOpen, setValue: setIsOpen } = useControllableState({
    value: open,
    defaultValue: defaultOpen,
    onChange: onOpenChange,
  })
  const { value: stepIndex, setValue: setStepIndex } = useControllableState({
    value: step,
    defaultValue: defaultStep,
    onChange: onStepChange,
  })
  const currentStep = isOpen ? steps[stepIndex] : undefined
  const isLastStep = stepIndex === steps.length - 1
  const targetName = currentStep?.target ?? null
  useScrollTargetIntoView(targetName)

  const virtualAnchor = React.useMemo(
    () => ({
      current: {
        getBoundingClientRect: () =>
          (targetName && findTourTarget(targetName)?.getBoundingClientRect()) ||
          new DOMRect(),
      },
    }),
    [targetName]
  )

  function endTour() {
    setIsOpen(false)
    setStepIndex(0)
  }

  function goNext() {
    if (!isLastStep) return setStepIndex(stepIndex + 1)
    endTour()
    onComplete?.()
  }

  function goBack() {
    if (stepIndex > 0) setStepIndex(stepIndex - 1)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    switch (event.key) {
      case "ArrowRight":
        return goNext()
      case "ArrowLeft":
        return goBack()
    }
  }

  return (
    <>
      {children}
      {currentStep && (
        <Popover open modal onOpenChange={(next) => !next && endTour()}>
          <PopoverAnchor virtualRef={virtualAnchor} />
          <TourSpotlight target={currentStep.target} />
          <PopoverContent
            data-slot="tour-card"
            role="dialog"
            aria-labelledby={titleId}
            side={currentStep.side ?? "bottom"}
            sideOffset={14}
            updatePositionStrategy="always"
            onOpenAutoFocus={(event) => {
              event.preventDefault()
              if (document.activeElement instanceof HTMLElement)
                returnFocusRef.current = document.activeElement
              nextButtonRef.current?.focus()
            }}
            onCloseAutoFocus={(event) => {
              event.preventDefault()
              returnFocusRef.current?.focus()
              returnFocusRef.current = null
            }}
            onInteractOutside={(event) => event.preventDefault()}
            onKeyDown={handleKeyDown}
            className="flex w-80 flex-col gap-3"
          >
            <div className="flex items-start justify-between gap-4">
              <div className="flex flex-col gap-1">
                <span className="font-mono text-[11px] text-muted-foreground">
                  {stepIndex + 1} of {steps.length}
                </span>
                <h2 id={titleId} className="leading-none font-medium">
                  {currentStep.title}
                </h2>
              </div>
              <Button
                variant="ghost"
                size="icon"
                aria-label="End tour"
                onClick={endTour}
                className="-mt-1 -mr-2 size-7"
              >
                <XIcon />
              </Button>
            </div>
            {currentStep.description && (
              <div className="text-sm text-muted-foreground">
                {currentStep.description}
              </div>
            )}
            <div className="flex items-center justify-between">
              <TourProgress count={steps.length} current={stepIndex} />
              <div className="flex gap-2">
                {stepIndex > 0 && (
                  <Button variant="ghost" size="sm" onClick={goBack}>
                    Back
                  </Button>
                )}
                <Button ref={nextButtonRef} size="sm" onClick={goNext}>
                  {isLastStep ? "Finish" : "Next"}
                </Button>
              </div>
            </div>
          </PopoverContent>
        </Popover>
      )}
    </>
  )
}

export { Tour, TourTarget, type TourStep }