ProductTour
FeedbackAn optional modal workspace guide with highlighted targets, step controls, and explicit focus return.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { useRef, useState } from "react";
import { ProductTour } from "@/components/ui/product-tour";
import { Button } from "@dashboardpack/core/components/ui/button";
export default function ProductTourExample() {
const [open, setOpen] = useState(false);
const [completed, setCompleted] = useState(false);
const start = useRef<HTMLButtonElement>(null);
const overview = useRef<HTMLDivElement>(null);
const activity = useRef<HTMLDivElement>(null);
return (
<div className="w-full max-w-2xl space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-widest text-primary">
Make yourself at home
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
A guided first visit.
</h3>
</div>
<Button ref={start} onClick={() => setOpen(true)}>
Take the tour
</Button>
</div>
<div
ref={overview}
className="grid grid-cols-2 gap-4 rounded-2xl border bg-card p-5"
>
<div>
<p className="text-xs text-muted-foreground">Active projects</p>
<p className="mt-2 text-3xl font-semibold">24</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Ready to launch</p>
<p className="mt-2 text-3xl font-semibold text-primary">08</p>
</div>
</div>
<div ref={activity} className="rounded-2xl border bg-card p-5">
<h4 className="font-semibold">Your workspace, in motion</h4>
<div className="mt-4 space-y-3">
{[
"Brand refresh approved",
"Release checklist updated",
"Studio handoff ready",
].map((item, index) => (
<div className="flex items-center gap-3 text-sm" key={item}>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs text-primary">
0{index + 1}
</span>
{item}
</div>
))}
</div>
</div>
<p role="status" className="text-xs text-muted-foreground">
{completed
? "Tour completed. You can start it again any time."
: "An optional tour with highlighted targets, Back/Next, Escape, and focus return."}
</p>
<ProductTour
open={open}
onOpenChange={setOpen}
onComplete={() => setCompleted(true)}
returnFocusRef={start}
steps={[
{
id: "overview",
title: "Your workspace at a glance",
description:
"See active projects and launch readiness without leaving your overview.",
target: () => overview.current,
},
{
id: "activity",
title: "Keep the team moving",
description:
"Recent activity brings approvals, updates, and handoffs into one place.",
target: () => activity.current,
},
{
id: "ready",
title: "You’re ready to explore",
description:
"This guide is optional. You can close it at any point and return to the workspace.",
},
]}
/>
</div>
);
}Usage
Own open/onOpenChange and supply steps with stable IDs, titles, descriptions, and optional target getter functions. Start a tour from an explicit user action. The guide scrolls each available target into view, measures its rectangle, and refreshes highlighting on scroll, resize, and target resize. A missing, hidden, or removed target does not prevent progressing through its explanation. Closing and reopening starts at the first step. onComplete fires only from Finish, not Skip or Escape. The modal blocks background interactions; describe the feature and let the user act after closing. Use onboarding state in your application if a tour should be remembered; no persistence is implied.
import { ProductTour } from "@/components/ui/product-tour";Shipped as editable source in src/components/ui/product-tour.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/product-tour.tsx
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { Dialog } from "radix-ui";
import { X, ArrowRight, ArrowLeft, Sparkles } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface TourStep {
id: string;
title: string;
description: string;
target?: () => HTMLElement | null;
}
export interface ProductTourProps {
steps: TourStep[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete?: () => void;
returnFocusRef?: RefObject<HTMLElement | null>;
className?: string;
}
export function ProductTour(props: ProductTourProps) {
return props.open && props.steps.length > 0 ? (
<TourSession {...props} />
) : null;
}
function TourSession({
steps,
onOpenChange,
onComplete,
returnFocusRef,
className,
}: ProductTourProps) {
const [index, setIndex] = useState(0);
const position = Math.min(index, steps.length - 1);
const step = steps[position];
const [spotlight, setSpotlight] = useState<{
id: string;
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const origin = useRef<HTMLElement | null>(null);
useEffect(() => {
let frame = 0;
const measure = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const target = step.target?.();
const bounds = target?.isConnected
? target.getBoundingClientRect()
: null;
setSpotlight(
bounds && bounds.width > 0 && bounds.height > 0
? {
id: step.id,
x: Math.max(4, bounds.x - 6),
y: Math.max(4, bounds.y - 6),
width: Math.min(
innerWidth - Math.max(4, bounds.x - 6) - 4,
bounds.width + 12,
),
height: Math.min(
innerHeight - Math.max(4, bounds.y - 6) - 4,
bounds.height + 12,
),
}
: null,
);
});
};
step.target?.()?.scrollIntoView({ block: "center", behavior: "instant" });
measure();
window.addEventListener("resize", measure);
window.addEventListener("scroll", measure, true);
const observer = new ResizeObserver(measure);
const target = step.target?.();
if (target) observer.observe(target);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener("resize", measure);
window.removeEventListener("scroll", measure, true);
};
}, [step]);
return (
<Dialog.Root open onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/60" />
{spotlight?.id === step.id &&
spotlight.width > 0 &&
spotlight.height > 0 && (
<div
aria-hidden
data-tour-highlight
className="pointer-events-none fixed z-50 rounded-xl border-2 border-white ring-4 ring-primary"
style={{
left: spotlight.x,
top: spotlight.y,
width: spotlight.width,
height: spotlight.height,
}}
/>
)}
<Dialog.Content
onOpenAutoFocus={() => {
origin.current = document.activeElement as HTMLElement | null;
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
const target = returnFocusRef?.current ?? origin.current;
if (target?.isConnected) target.focus();
}}
className={cn(
"fixed bottom-4 left-1/2 z-[51] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 overflow-auto rounded-2xl border bg-card p-5 text-card-foreground shadow-2xl sm:bottom-8 sm:p-6",
className,
)}
>
<div className="mb-4 flex items-center justify-between">
<span className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
<Sparkles className="size-3" />
Workspace guide
</span>
<Dialog.Close asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close tour"
>
<X className="size-4" />
</Button>
</Dialog.Close>
</div>
<p role="status" className="mb-2 text-xs text-muted-foreground">
Step {position + 1} of {steps.length}
</p>
<Dialog.Title className="text-xl font-semibold tracking-tight">
{step.title}
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm leading-6 text-muted-foreground">
{step.description}
</Dialog.Description>
<div className="mt-5 flex gap-1.5" aria-hidden>
{steps.map((item, itemIndex) => (
<span
key={item.id}
className={cn(
"h-1 flex-1 rounded-full",
itemIndex <= position ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
>
Skip tour
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={position === 0}
onClick={() => setIndex(position - 1)}
>
<ArrowLeft className="size-3.5 rtl:rotate-180" />
Back
</Button>
<Button
type="button"
size="sm"
onClick={() => {
if (position < steps.length - 1) setIndex(position + 1);
else {
onComplete?.();
onOpenChange(false);
}
}}
>
{position === steps.length - 1 ? "Finish tour" : "Next"}
<ArrowRight className="size-3.5 rtl:rotate-180" />
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| steps | TourStep[]Default: required | Unique id, title, description, and optional () => HTMLElement | null target. Empty steps render nothing. |
| open / onOpenChange | boolean / callbackDefault: required | Parent-controlled modal visibility. A new session starts at step one. |
| onComplete | () => voidDefault: — | Called when the user finishes the final step. |
| returnFocusRef | RefObject<HTMLElement | null>Default: original focus | Explicit opener for focus restoration on dismissal. |
| className | stringDefault: — | Extend the responsive guide panel styling. |