Skip to content

ProductTour

Feedback

An optional modal workspace guide with highlighted targets, step controls, and explicit focus return.

Highlighted workspace guideBack / Next / FinishMissing target fallbackSkip / Escape / restart
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

ProductTour props
PropType / DefaultDescription
stepsTourStep[]

Default: required

Unique id, title, description, and optional () => HTMLElement | null target. Empty steps render nothing.
open / onOpenChangeboolean / callback

Default: required

Parent-controlled modal visibility. A new session starts at step one.
onComplete() => void

Default: —

Called when the user finishes the final step.
returnFocusRefRefObject<HTMLElement | null>

Default: original focus

Explicit opener for focus restoration on dismissal.
classNamestring

Default: —

Extend the responsive guide panel styling.

Build with these too