Product tour
Spotlit steps anchored to the page — where you are in the walk, back, next and skip, the keyboard, the focus kept in the step and returned after.
Open it from a button — open and onOpenChange are yours — and it lays a layer over the page that takes every press and dims it with the kit's modal scrim — the --overlay a dialog lays down, darker in the dark theme too — with a cutout six pixels round each step's target, and draws the step as the kit's PopoverContent anchored to that element: a dialog named by its title and described by its body, with "Step 2 of 5", Back (not on the first step), Next or Done, and Skip tour. A target is a selector or a function that returns the element. Each is scrolled to the middle of the screen, and the cutout and the card follow it as the page scrolls, the window resizes and the target changes size, at most once a frame. A target the page does not have, or one the reader cannot see — display:none, a closed drawer, the sidebar a phone tucks away — gets its step in the middle of the screen over a fully dimmed page, so a tour written at a desk still reads on a phone. The focus lands on Next, goes round the step's own controls on Tab, and returns to where it was — the button that opened the tour — when it ends. Done, Skip tour and Escape end it; a press on the dimmed page does not, and leaves the focus in the step — as does anything else that would send it out onto the page. ArrowRight and ArrowLeft are Next and Back in reading order, so they swap on a right-to-left page, and a status line says each move ("Step 2 of 5: Search"). Under the OS setting or [data-motion="reduced"] the scroll jumps and the cutout stops gliding, and the step and the layer carry the flag data-reduced (never data-motion, which is the switch a page sets to ask). The tour remembers itself only under storageKey: Done stores "true" in localStorage under that key — Skip and Escape do not — and useTourSeen(storageKey) reads it on this page and on a later visit; without a key nothing is written, and a key you pass is one your app should list wherever it says what it keeps in the browser. Tours across routes are not covered: a step points at the page it is on.
Install
npx shadcn@latest add @vibra/product-tourNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { BellIcon, SearchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ProductTour, type TourStep } from "@/components/ui/product-tour"
export default function ProductTourDemo() {
const id = React.useId()
const [open, setOpen] = React.useState(false)
// Selectors scoped to this copy of the demo, so two on one page never point at each other's parts.
const part = (name: string) => `[data-tour="${id}-${name}"]`
const steps: TourStep[] = [
{
id: "search",
target: part("search"),
title: "Search",
body: "Find any page, person or invoice from here.",
},
{
id: "inbox",
target: part("inbox"),
title: "Notifications",
body: "Approvals, mentions and failed payments land here. A dot means something is unread.",
align: "end",
},
{
id: "account",
target: part("account"),
title: "Your account",
body: "Your profile, the workspace's settings and signing out.",
align: "end",
},
{
id: "done",
target: part("nowhere"),
title: "That's the tour",
body: "Take it again from the help menu whenever you like.",
},
]
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<div className="flex h-12 items-center gap-3 rounded-lg bg-surface px-3">
<span className="text-sm font-semibold">Northwind</span>
<div data-tour={`${id}-search`} className="relative ms-auto w-full max-w-48">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 start-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="Search" placeholder="Search" className="ps-8" />
</div>
<Button data-tour={`${id}-inbox`} variant="ghost" size="icon-sm" aria-label="Notifications">
<BellIcon />
</Button>
<Button data-tour={`${id}-account`} variant="ghost" size="icon-sm" aria-label="Account">
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-full bg-foreground text-xs font-medium text-background">
PL
</span>
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">New here? Four stops, under a minute.</p>
<Button onClick={() => setOpen(true)}>Take the tour</Button>
</div>
<ProductTour steps={steps} open={open} onOpenChange={setOpen} />
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { id: string; target: string | (() => HTMLElement | null); title: string; body: React.ReactNode; side?: "top" | "bottom" | "inline-start" | "inline-end"; align?: "start" | "center" | "end" }[] | — | The walk, in order. side and align place a step against its target ("bottom" and "center" by default), and Base UI flips it where there is no room. |
| open | boolean | — | Whether the tour is showing; it draws nothing while closed. |
| onOpenChange | (open: boolean) => void | — | Called with false on Done, Skip tour and Escape. |
| step | number | — | The step shown, when you hold it; controlled when given. |
| defaultStep | number | 0 | Where the tour starts each time it opens, when it holds its own step. |
| onStepChange | (index: number) => void | — | Called with the step Back, Next or an arrow key moves to. |
| onFinish | () => void | — | Called on Done, before the tour closes; never on Skip tour or Escape. |
| storageKey | string | — | Remembers a finished tour: Done stores "true" in localStorage under this key. Without it nothing is written. |
| labels | { next?: string; back?: string; done?: string; skip?: string } | "Next", "Back", "Done", "Skip tour" | The buttons' words, for another language. |
| useTourSeen | (storageKey: string) => [seen: boolean, setSeen: (seen: boolean) => void] | — | Whether the tour under the key was finished, on this page or an earlier visit — false through the server render and the one that hydrates it — and a way to set or clear it. |
Dependencies
Source
"use client"
import * as React from "react"
import { useLocalStorage } from "@/hooks/use-local-storage"
import { prefersReducedMotion, useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverDescription, PopoverTitle } from "@/components/ui/popover"
import { Spotlight } from "@/components/ui/product-tour/spotlight"
import { useTargetRect } from "@/components/ui/product-tour/use-target-rect"
export type TourStep = {
id: string
/** A selector, or a function that finds the element; missing or hidden, the step is centred. */
target: string | (() => HTMLElement | null)
title: string
body: React.ReactNode
side?: "top" | "bottom" | "inline-start" | "inline-end"
align?: "start" | "center" | "end"
}
export type ProductTourProps = {
steps: TourStep[]
open: boolean
onOpenChange: (open: boolean) => void
step?: number
defaultStep?: number
onStepChange?: (index: number) => void
onFinish?: () => void
storageKey?: string
labels?: { next?: string; back?: string; done?: string; skip?: string }
}
/** Writes the flag, then tells every useTourSeen on this page: the browser's own storage event reaches only the other tabs. */
function storeSeen(storageKey: string, seen: boolean) {
const value = JSON.stringify(seen)
try {
window.localStorage.setItem(storageKey, value)
} catch {
// Storage blocked or full: this page still hears it below, the next visit does not.
}
window.dispatchEvent(new StorageEvent("storage", { key: storageKey, newValue: value }))
}
/** Whether the tour under `storageKey` was finished, here or on an earlier visit, and a way to set it. False until mounted. */
export function useTourSeen(storageKey: string): [seen: boolean, setSeen: (seen: boolean) => void] {
const [seen] = useLocalStorage<boolean>(storageKey, false)
const setSeen = React.useCallback((next: boolean) => storeSeen(storageKey, next), [storageKey])
return [seen, setSeen]
}
/** The reading direction an element sits in: the nearest dir attribute, else its computed direction. */
function directionOf(element: HTMLElement): "ltr" | "rtl" {
const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (attribute === "rtl" || attribute === "ltr") return attribute
return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}
/** A point at the middle of the viewport, which a step with nothing to point at is centred on. */
const VIEWPORT_CENTRE = {
getBoundingClientRect: () => {
const x = window.innerWidth / 2
const y = window.innerHeight / 2
return { x, y, top: y, left: x, right: x, bottom: y, width: 0, height: 0 }
},
}
const TABBABLE = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
function OpenTour({ steps, onOpenChange, step, defaultStep = 0, onStepChange, onFinish, storageKey, labels }: ProductTourProps) {
const controlled = step !== undefined
const [internal, setInternal] = React.useState(defaultStep)
const index = Math.min(Math.max(controlled ? step : internal, 0), steps.length - 1)
const current = steps[index]
const last = index === steps.length - 1
const reduced = useReducedMotion()
const primary = React.useRef<HTMLButtonElement>(null)
const stepRef = React.useRef<HTMLDivElement>(null)
// The focus stays in the step whatever moves it: a press on the veil keeps
// it where it was (spotlight.tsx), and anything else that sends it out onto
// the page — a script, a screen reader's cursor — brings it back to Next.
React.useEffect(() => {
function holdFocus(event: FocusEvent) {
const step = stepRef.current
if (!step?.isConnected || (event.target instanceof Node && step.contains(event.target))) return
primary.current?.focus()
}
document.addEventListener("focusin", holdFocus)
return () => document.removeEventListener("focusin", holdFocus)
}, [])
// The step's element and where it is, read from the page as it moves; null
// for one the page does not have or the reader cannot see.
const found = useTargetRect(current.target)
const target = found?.element ?? null
const rect = found?.rect ?? null
const placement = rect ? "anchored" : "center"
React.useEffect(() => {
target?.scrollIntoView({ block: "center", inline: "nearest", behavior: prefersReducedMotion(target) ? "auto" : "smooth" })
}, [target])
function go(next: number) {
if (!controlled) setInternal(next)
onStepChange?.(next)
}
function back() {
// Back is not drawn on the first step: the focus moves on before it goes.
if (index === 1) primary.current?.focus()
go(index - 1)
}
function finish() {
if (storageKey) storeSeen(storageKey, true)
onFinish?.()
onOpenChange(false)
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Tab") {
// The step keeps the focus: Tab goes round its own controls.
const tabbable = [...event.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)]
const edge = event.shiftKey ? tabbable[0] : tabbable.at(-1)
if (edge && document.activeElement === edge) {
event.preventDefault()
;(event.shiftKey ? tabbable.at(-1) : tabbable[0])?.focus()
}
return
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
// Alt+Arrow and the like are the browser's — history, words, the line's ends.
if (event.altKey || event.ctrlKey || event.metaKey) return
if (event.target instanceof HTMLElement && event.target.closest("input, textarea, select, [contenteditable]")) return
// Next is the way the page reads: to the left on a right-to-left page.
const forward = (event.key === "ArrowRight") === (directionOf(event.currentTarget) === "ltr")
event.preventDefault()
if (forward && !last) go(index + 1)
else if (!forward && index > 0) back()
}
const text = {
next: labels?.next ?? "Next",
back: labels?.back ?? "Back",
done: labels?.done ?? "Done",
skip: labels?.skip ?? "Skip tour",
}
return (
<>
<Spotlight rect={rect} reduced={reduced} />
<Popover
open
// Escape ends the tour; a press on the dimmed page or the focus
// wandering off does not.
onOpenChange={(open, details) => {
if (!open && details.reason === "escape-key") onOpenChange(false)
}}
>
{/* COUPLED TO registry/vibra/ui/popover.tsx: a step is wider and roomier than a popover's w-72 p-2.5. */}
<PopoverContent
anchor={rect ? target : VIEWPORT_CENTRE}
side={rect ? (current.side ?? "bottom") : "bottom"}
align={rect ? (current.align ?? "center") : "center"}
// Clear of the cutout's six pixels; centred, half its own height back up.
sideOffset={rect ? 12 : ({ positioner }) => -positioner.height / 2}
ref={stepRef}
initialFocus={primary}
aria-modal="true"
data-slot="product-tour-step"
data-placement={placement}
data-reduced={reduced || undefined}
onKeyDown={onKeyDown}
className="w-80 max-w-(--available-width) gap-4 p-4"
>
<div className="flex flex-col gap-1">
<p className="type-eyebrow tabular-nums">
Step {index + 1} of {steps.length}
</p>
<PopoverTitle className="text-sm font-semibold">{current.title}</PopoverTitle>
<PopoverDescription render={<div />} className="text-sm">
{current.body}
</PopoverDescription>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="-ms-2.5 text-muted-foreground">
{text.skip}
</Button>
<div className="ms-auto flex items-center gap-2">
{index > 0 ? (
<Button type="button" variant="outline" size="sm" onClick={back}>
{text.back}
</Button>
) : null}
<Button ref={primary} type="button" size="sm" onClick={last ? finish : () => go(index + 1)}>
{last ? text.done : text.next}
</Button>
</div>
</div>
{/* The step's own name is heard when the focus arrives; this says each move after it. */}
<p role="status" className="sr-only">
Step {index + 1} of {steps.length}: {current.title}
</p>
</PopoverContent>
</Popover>
</>
)
}
/**
* A guided walk through a page: each step spotlights one element, with its
* title, its words, where it is in the walk, and Back, Next or Done and Skip
* tour. The page under it is dimmed and out of reach, the focus stays in the
* step and returns where it was at the end, and a target the reader cannot
* see gets its step in the middle of the screen instead. Draws nothing while
* it is closed.
*/
export function ProductTour(props: ProductTourProps) {
if (!props.open || props.steps.length === 0) return null
return <OpenTour {...props} />
}"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import type { TargetRect } from "@/components/ui/product-tour/use-target-rect"
/** The room left round a target inside its cutout. */
const PADDING = 6
const subscribeToNothing = () => () => {}
/** A press on the dimmed page goes nowhere, and takes the focus nowhere either: it stays in the step. */
const keepFocus = (event: React.PointerEvent | React.MouseEvent) => event.preventDefault()
/**
* The page under a tour step, dimmed and out of reach: a fixed layer over the
* viewport that takes every press, with a cutout round the target drawn as
* one box whose shadow spreads the kit's modal scrim — the --overlay a dialog
* lays down, which darkens the page in either theme — over the rest of it.
* With no target the cutout closes to a point at the centre and the whole page
* dims. Moving from one target to the next, the cutout travels on the motion
* tokens, so it jumps when less motion is asked for.
*/
export function Spotlight({ rect, reduced }: { rect: TargetRect | null; reduced: boolean }) {
// The layer lives in document.body, which a server render has no hold on.
const mounted = React.useSyncExternalStore(subscribeToNothing, () => true, () => false)
if (!mounted) return null
return createPortal(
<div
data-slot="product-tour"
data-placement={rect ? "anchored" : "center"}
data-reduced={reduced || undefined}
aria-hidden="true"
onPointerDown={keepFocus}
onMouseDown={keepFocus}
className="fixed inset-0 z-50 animate-in fade-in-0 duration-(--duration-base) ease-(--ease-standard)"
>
<div
data-slot="product-tour-spotlight"
className="absolute rounded-lg shadow-[0_0_0_9999px_var(--overlay)] transition-[top,left,width,height] duration-(--duration-base) ease-(--ease-standard)"
style={
rect
? {
top: rect.top - PADDING,
left: rect.left - PADDING,
width: rect.width + PADDING * 2,
height: rect.height + PADDING * 2,
}
: { top: "50%", left: "50%", width: 0, height: 0 }
}
/>
</div>,
document.body
)
}"use client"
import * as React from "react"
/** Where an element sits in the viewport, in pixels. */
export type TargetRect = { top: number; left: number; width: number; height: number }
/** A selector, or a function that finds the element. */
export type TargetSpec = string | (() => Element | null)
/** The element a spec names, if the page has one: a selector that matches nothing, or is no selector at all, finds none. */
function findTarget(target: TargetSpec): Element | null {
if (typeof target === "function") return target()
try {
return document.querySelector(target)
} catch {
return null
}
}
/**
* Whether a reader can see an element: drawn, with nothing above it
* display:none and itself not visibility:hidden. `checkVisibility` answers in
* a browser; an engine without it (an older one, a test's DOM) is asked the
* same of each ancestor's computed style.
*/
function isShown(element: Element): boolean {
if (typeof element.checkVisibility === "function") return element.checkVisibility({ visibilityProperty: true })
for (let node: Element | null = element; node; node = node.parentElement) {
if (getComputedStyle(node).display === "none") return false
}
return getComputedStyle(element).visibility !== "hidden"
}
type Found = { element: Element; rect: TargetRect }
const same = (a: TargetRect, b: TargetRect) => a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height
/**
* A tour step's target and its viewport rect, read as an outside store: the
* page scrolling — any scroller, heard in the capture phase — the window
* resizing and the target changing size each ask for a fresh read, at most
* once a frame. Null for a target the page does not have, or one the reader
* cannot see.
*/
export function useTargetRect(target: TargetSpec): Found | null {
// The last answer, handed back while nothing has moved, as an outside
// store's snapshot has to be.
const last = React.useRef<Found | null>(null)
const subscribe = React.useCallback(
(onChange: () => void) => {
let frame = 0
const schedule = () => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
onChange()
})
}
window.addEventListener("resize", schedule)
window.addEventListener("scroll", schedule, { capture: true, passive: true })
const element = findTarget(target)
const observer = element && typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : undefined
if (element) observer?.observe(element)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener("resize", schedule)
window.removeEventListener("scroll", schedule, { capture: true })
observer?.disconnect()
}
},
[target]
)
const getSnapshot = React.useCallback((): Found | null => {
const element = findTarget(target)
const previous = last.current
if (!element || !element.isConnected || !isShown(element)) return (last.current = null)
const box = element.getBoundingClientRect()
const rect = { top: box.top, left: box.left, width: box.width, height: box.height }
if (previous && previous.element === element && same(previous.rect, rect)) return previous
return (last.current = { element, rect })
}, [target])
return React.useSyncExternalStore(subscribe, getSnapshot, () => null)
}