Product tour and feature spotlight in React

Build a product tour in React: a spotlight cutout over real UI, a popover anchored to each target, step state, keyboard support and a seen flag.

By , MiniDev18 min read

A product tour in React is a list of steps, each pointing at a real element, plus three pieces of UI: a dimmed overlay with a cutout around the target, a popover anchored to it with the explanation and Next, and a seen flag so it only runs once. None of that needs a tour library; the anchored popover is the hard part and the kit's Popover (built on Base UI) already positions, flips and dismisses.

This guide builds that in-app tour from Popover and Button, reuses it as a one step feature spotlight, and shows where the kit's ProductTour panel fits, which is a different thing: a step list beside a stage for onboarding and marketing pages, not an overlay. It ends with KeyboardShortcuts and Kbd for the tour's last step, where you hand people the shortcuts.

Two kinds of tour

In-app walkthroughTour panel
WhereOver the real product, after first sign inOn a landing page, docs home or the empty dashboard
Points atLive elements, found by selectorNothing; each step is text beside a stage
Built fromPopover, an overlay div, your stateProductTour as shipped
RiskBreaks when the UI changes; must handle missing targetsNone, it is static

Most products want the panel for the empty state and the walkthrough for two or three genuinely non obvious features. Resist touring the whole app: five steps is a lot, and people skip tours that explain buttons with labels.

Install

bash
npx shadcn@latest add @minidev/popover
npx shadcn@latest add @minidev/button
npx shadcn@latest add @minidev/product-tour
npx shadcn@latest add @minidev/keyboard-shortcuts

ProductTour is one of the kit's Motion components and animates its stage with motion/react; KeyboardShortcuts pulls in dialog, input and kbd. Import the token stylesheet once (styles.css) so the overlay scrim, bg-raised and the accent ring resolve.

The panel: ProductTour as shipped

ProductTour takes steps as { title, body } objects and renders them as a column of buttons beside a stage that fades to the selected step, with Back and Next underneath. The active index is internal state; there is no controlled active prop and no onChange, so it is a self contained explainer rather than something you wire to the rest of the page.

tsx
<ProductTour
  steps={[
    { title: "Connect your bank", body: "Link accounts once. Transactions sync hourly and match to invoices." },
    { title: "Send your first invoice", body: "Pick a customer, add line items, and Lumen handles numbering and reminders." },
    { title: "Invite the team", body: "Roles for approvals and payouts, so nothing waits on one person." },
  ]}
/>

The stage shows the step's title and body as text. To show a screenshot or a live component per step, add an optional visual?: React.ReactNode to TourStep in your copy and render it in the stage above the heading; it is a five line change because the file is yours. Each step button carries aria-current="step" when selected, which is the right semantics for a list of steps.

Product TourmotionA React product tour with a clickable list of steps beside a detail panel that fades in for the active step, plus back and next buttons that stop at the ends.npx shadcn@latest add @minidev/product-tour

The walkthrough: steps that point at real elements

Describe each step by a CSS selector, a title, a body and a preferred side. Use data-tour attributes as the selectors rather than class names, so a restyle cannot break the tour:

app/tour-steps.tsts
export type WalkStep = {
  target: string
  title: string
  body: string
  side?: "top" | "bottom" | "left" | "right"
}

export const DASHBOARD_TOUR: WalkStep[] = [
  { target: "[data-tour='new-invoice']", title: "Create an invoice", body: "Start here. Drafts save as you type.", side: "bottom" },
  { target: "[data-tour='filters']", title: "Filter by status", body: "Overdue, paid and drafts are one click away.", side: "bottom" },
  { target: "[data-tour='command']", title: "Jump anywhere", body: "Press the shortcut shown here to open the command palette.", side: "left" },
]

The tour component below measures the target, draws the cutout, anchors the popover and owns the step index. The one thing it needs from the kit's PopoverContent that is not exposed by default is the anchor prop, covered in the next section.

components/walkthrough.tsxtsx
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle } from "@/components/ui/popover"
import type { WalkStep } from "@/app/tour-steps"

const PAD = 6

export function Walkthrough({ steps, open, onClose }: { steps: WalkStep[]; open: boolean; onClose: () => void }) {
  const [i, setI] = React.useState(0)
  const [el, setEl] = React.useState<HTMLElement | null>(null)
  const [rect, setRect] = React.useState<DOMRect | null>(null)
  const step = steps[i]
  const last = i === steps.length - 1

  React.useEffect(() => { if (open) setI(0) }, [open])

  // Find and measure the target. Re-measure on scroll and resize so the cutout follows it.
  React.useEffect(() => {
    if (!open || !step) return
    const target = document.querySelector<HTMLElement>(step.target)
    setEl(target)
    setRect(null)
    if (!target) return
    const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches
    target.scrollIntoView({ block: "center", behavior: reduce ? "auto" : "smooth" })
    const measure = () => setRect(target.getBoundingClientRect())
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(target)
    window.addEventListener("scroll", measure, true)
    window.addEventListener("resize", measure)
    return () => {
      ro.disconnect()
      window.removeEventListener("scroll", measure, true)
      window.removeEventListener("resize", measure)
    }
  }, [open, step])

  // Skip steps whose target is not on the page.
  React.useEffect(() => {
    if (!open || !step) return
    if (!document.querySelector(step.target)) {
      if (last) onClose()
      else setI((n) => n + 1)
    }
  }, [open, step, last, onClose])

  React.useEffect(() => {
    if (!open) return
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "ArrowRight") setI((n) => Math.min(steps.length - 1, n + 1))
      if (e.key === "ArrowLeft") setI((n) => Math.max(0, n - 1))
    }
    document.addEventListener("keydown", onKey)
    return () => document.removeEventListener("keydown", onKey)
  }, [open, steps.length])

  if (!open || !step || !el || !rect) return null

  return (
    <>
      <div
        aria-hidden
        className="pointer-events-none fixed z-40 rounded-xl outline-2 outline-accent transition-[top,left,width,height] duration-200 ease-hairline motion-reduce:transition-none"
        style={{
          top: rect.top - PAD,
          left: rect.left - PAD,
          width: rect.width + PAD * 2,
          height: rect.height + PAD * 2,
          boxShadow: "0 0 0 100vmax var(--overlay-scrim)",
        }}
      />
      <Popover open onOpenChange={(o) => { if (!o) onClose() }}>
        <PopoverContent anchor={el} side={step.side ?? "bottom"} sideOffset={PAD + 8} className="w-80">
          <PopoverHeader>
            <PopoverTitle>{step.title}</PopoverTitle>
            <PopoverDescription>{step.body}</PopoverDescription>
          </PopoverHeader>
          <div className="flex items-center justify-between gap-2">
            <span role="status" className="text-xs text-fg-muted tabular-nums">Step {i + 1} of {steps.length}</span>
            <div className="flex gap-1.5">
              {!last ? <Button variant="ghost" size="sm" onClick={onClose}>Skip</Button> : null}
              <Button variant="outline" size="sm" disabled={i === 0} onClick={() => setI(i - 1)}>Back</Button>
              <Button size="sm" onClick={() => (last ? onClose() : setI(i + 1))}>{last ? "Done" : "Next"}</Button>
            </div>
          </div>
        </PopoverContent>
      </Popover>
    </>
  )
}
  • The cutout is one fixed div placed over the target with a huge box-shadow in the scrim colour. The shadow covers the whole viewport except the element's box, which is the cheapest way to dim everything but one thing, and it needs no SVG mask.
  • pointer-events-none on that div keeps the target clickable, so a step can say "try it" and the user can. If you want the page locked during the tour, remove that class and add role="presentation".
  • Steps whose selector finds nothing are skipped instead of showing a popover anchored to nowhere. This is the failure mode that makes tours look broken after a redesign.
  • Arrow keys are handled at the document level so they work wherever focus is. Escape, outside clicks and focus return are Base UI's job through onOpenChange.

Anchoring the popover to an element

Base UI's Popover.Positioner accepts an anchor prop (an element, a ref, or a function returning one), which lets a popover float next to anything, not only its trigger. The kit's PopoverContent passes align, alignOffset, side and sideOffset through to the positioner; add anchor to that list in your copy:

components/ui/popover.tsxtsx
function PopoverContent({
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  anchor,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "anchor">) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} anchor={anchor} className="isolate z-50">

With open controlled and no PopoverTrigger rendered, the popover has no trigger to return focus to, so Base UI returns it to wherever it was. The tour moves focus into the popover on each step (the Next button is the first focusable element), which means Tab from the page lands on the tour controls and Escape takes you back where you were.

Starting it once, and only when it makes sense

Store a seen flag per tour and per version, so a changed tour can run again. Server side storage is best because it follows the user across devices; localStorage is the fallback and is fine for a first version:

tsx
const KEY = "tour:dashboard:v2"

export function DashboardTour() {
  const [open, setOpen] = React.useState(false)
  React.useEffect(() => {
    try {
      if (localStorage.getItem(KEY)) return
    } catch {}
    if (matchMedia("(pointer: coarse)").matches) return // the layout differs on phones
    const t = setTimeout(() => setOpen(true), 800)
    return () => clearTimeout(t)
  }, [])
  const close = () => {
    setOpen(false)
    try { localStorage.setItem(KEY, "1") } catch {}
  }
  return <Walkthrough steps={DASHBOARD_TOUR} open={open} onClose={close} />
}
  • The short delay lets the page settle so the first target has its final position before it is measured.
  • Skip counts as seen. A tour that returns every session because someone pressed Skip is the fastest way to teach people to hate it.
  • Keep a "Take the tour" entry in a help menu that calls setOpen(true), so the tour is still reachable after the flag is set.

A one step feature spotlight

Announcing one new feature is the same machinery with one step: the cutout draws attention, the popover says what changed, and the only button is "Got it". Pass a single step and the component already renders Done instead of Next and hides Skip. Key the seen flag by the feature (spotlight:bulk-export) and remove the code after a release or two; permanent spotlights become noise.

The kit's CursorSpotlightPanel is a different kind of spotlight: a marketing panel where a soft radial light follows the cursor across the surface. It takes title, body and className, has no steps or targets, and sits well on a features page. It is not part of the tour machinery.

The last step: hand over the shortcuts

A good final step teaches the keyboard. KeyboardShortcuts renders a searchable cheat sheet in a dialog, opens on ? anywhere outside a text field, and shows the right modifier glyphs for the viewer's platform (it reads the platform after mount so server and client markup match). Control it from the tour's Done button:

tsx
const [sheet, setSheet] = React.useState(false)

<Walkthrough steps={DASHBOARD_TOUR} open={open} onClose={() => { close(); setSheet(true) }} />
<KeyboardShortcuts
  open={sheet}
  onOpenChange={setSheet}
  showTrigger={false}
  groups={[
    { title: "Navigation", shortcuts: [{ keys: ["mod", "K"], label: "Command palette" }, { keys: ["G", "I"], label: "Go to invoices" }] },
    { title: "Invoices", shortcuts: [{ keys: ["N"], label: "New invoice" }, { keys: ["mod", "enter"], label: "Send" }] },
  ]}
/>

Write keys once with portable names (mod, shift, alt, enter, esc) and they render as ⌘ ⇧ ⌥ ↵ on a Mac and Ctrl, Shift, Alt, Enter elsewhere. Inside tour copy, show a single key with Kbd: "Press <Kbd>?</Kbd> any time to see these again." The command palette guide covers the shortcut the first group points at.

Accessibility

  • The step counter is a role="status" element inside the popover, so each step change is announced as "Step 2 of 3" along with the new title.
  • The popover carries PopoverTitle and PopoverDescription, which Base UI wires to aria-labelledby and aria-describedby on the dialog surface. See ARIA.
  • The cutout div is aria-hidden; it is purely visual. Screen reader users get the same content through the popover and are not trapped behind a scrim.
  • Both scrollIntoView and the cutout transition respect prefers-reduced-motion.
  • Do not auto start a tour while a dialog is open or a form is half filled. Check for document.querySelector("[role=dialog]") before opening.

Pitfalls

  • Selectors that match nothing after a refactor. Use data-tour attributes, skip missing targets, and add a test that asserts every selector in the step list exists on the dashboard page.
  • Measuring before layout settles. Fonts, images and data all move things. Delay the start and re-measure on scroll and resize, as the component does.
  • Targets inside a scroll container. The scroll listener uses capture (true) so nested scrollers trigger a re-measure too. scrollIntoView handles nested containers on its own.
  • A tour on a phone. Popovers next to a bottom bar flip, cutouts cover half the screen. Skip the tour on coarse pointers or write a separate, shorter one.
  • Toured elements that are rendered later by Suspense. If the target streams in, start the tour from the component that renders it, not from the layout.

Components used in this guide

Popover, Button, KeyboardShortcuts and Kbd are free and MIT licensed; ProductTour and CursorSpotlightPanel are in the animation category. The tour usually runs over the layout from the sidebar layout guide and points at the first empty state of the product. If you want the whole onboarding experience built, the MiniDev studio builds products on this kit.

PopoveruiA floating React panel anchored to a trigger, built on Base UI with side and align options, collision aware positioning, title and description parts.npx shadcn@latest add @minidev/popoverKeyboard ShortcutsuiKeyboard Shortcuts is a React cheat sheet dialog of grouped shortcuts opened with the ? key or a button, searchable, with Command glyphs on Mac and Ctrl elsewhere.npx shadcn@latest add @minidev/keyboard-shortcutsCursor Spotlight PanelmotionA panel whose accent radial spotlight follows the cursor, driven by Framer Motion values without re-renders. It shows a still surface for reduced motion.npx shadcn@latest add @minidev/cursor-spotlight-panel
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

Frequently asked questions

Do I need a library like Shepherd or Intro.js for a product tour?

Not if you already have a popover that can anchor to an element. A tour is a step index, a measured target rectangle, a dimming overlay with a cutout and a seen flag, which is about a hundred lines on top of the kit's Popover. Libraries help when you need many tours managed by non developers.

How do I highlight one element and dim the rest of the page?

Place a fixed, pointer transparent div over the element's bounding rectangle and give it a very large box-shadow in the scrim colour (0 0 0 100vmax var(--overlay-scrim)). The shadow covers everything except the box, so the element stays visible and clickable.

Should a product tour start automatically?

Once, after the first meaningful page has rendered, and never again after Skip or Done. Keep it to a few steps, key the seen flag by tour version so an updated tour can run again, and leave a way to replay it from the help menu.

More guides