Product Tour
Navigation

Product Tour

Multi-step spotlight tour whose mask cutout morphs between targets on a zero-bounce spring, with the card traveling alongside, scroll-snap remeasures, focus trapping, and Esc/dim-click skip.

Install

npx shadcn@latest add @paragon/product-tour

Also installs: button

product-tour.tsx

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

export interface ProductTourStep {
  /** CSS selector or ref for the element to spotlight. */
  target: string | React.RefObject<HTMLElement | null>;
  title: React.ReactNode;
  body?: React.ReactNode;
}

export interface ProductTourProps {
  steps: ProductTourStep[];
  open: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Controlled step index. */
  step?: number;
  defaultStep?: number;
  onStepChange?: (index: number) => void;
  /** Fires when the user finishes the final step. */
  onComplete?: () => void;
  /** Fires when the tour is abandoned (Esc, dim click, or the X). */
  onSkip?: () => void;
  /** Scope selector lookups to this element — lets instances coexist. */
  container?: React.RefObject<HTMLElement | null>;
  /** Breathing room around the spotlit element, px. */
  padding?: number;
  /** Cutout corner radius, px. */
  radius?: number;
  backLabel?: string;
  nextLabel?: string;
  doneLabel?: string;
}

interface HoleRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

const CARD_WIDTH = 320;
const CARD_GAP = 12;
const VIEWPORT_MARGIN = 16;

/**
 * Multi-step spotlight tour. One SVG dim layer carries a mask whose cutout
 * rect is a single motion element — advancing a step MORPHS the hole to the
 * next target with a zero-bounce spring (the card travels on the same
 * spring), so the tour reads as one light moving through the product, not a
 * slideshow. Scroll/resize remeasure instantly instead of springing, the
 * first paint lands the cutout in place (no morph from nothing), Esc and dim
 * clicks skip, arrow keys navigate, focus is trapped in the card and
 * returned on close, and reduced motion swaps every morph for a plain fade.
 */
export function ProductTour({
  steps,
  open,
  onOpenChange,
  step: stepProp,
  defaultStep = 0,
  onStepChange,
  onComplete,
  onSkip,
  container,
  padding = 8,
  radius = 10,
  backLabel = "Back",
  nextLabel = "Next",
  doneLabel = "Done",
}: ProductTourProps) {
  const reducedMotion = useReducedMotion();
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const maskId = `pg-tour-mask-${uid}`;
  const titleId = `pg-tour-title-${uid}`;
  const bodyId = `pg-tour-body-${uid}`;

  const [mounted, setMounted] = React.useState(false);
  const [internalStep, setInternalStep] = React.useState(defaultStep);
  const index = Math.min(stepProp ?? internalStep, Math.max(steps.length - 1, 0));
  const setIndex = React.useCallback(
    (next: number) => {
      setInternalStep(next);
      onStepChange?.(next);
    },
    [onStepChange],
  );

  const [hole, setHole] = React.useState<HoleRect | null>(null);
  const [cardPos, setCardPos] = React.useState<{ x: number; y: number } | null>(
    null,
  );
  // Step changes spring; scroll/resize remeasures snap.
  const [animateGeo, setAnimateGeo] = React.useState(false);

  const cardRef = React.useRef<HTMLDivElement>(null);
  const previousFocus = React.useRef<HTMLElement | null>(null);
  const rafId = React.useRef(0);

  React.useEffect(() => setMounted(true), []);

  const currentStep = steps[index];

  const resolveTarget = React.useCallback(
    (step: ProductTourStep | undefined): HTMLElement | null => {
      if (!step) return null;
      if (typeof step.target === "string") {
        const root = container?.current ?? document;
        return root.querySelector<HTMLElement>(step.target);
      }
      return step.target.current;
    },
    [container],
  );

  const measure = React.useCallback(
    (animate: boolean) => {
      const el = resolveTarget(steps[index]);
      if (!el) {
        setHole(null);
        setAnimateGeo(animate);
        return;
      }
      const rect = el.getBoundingClientRect();
      setHole({
        x: rect.left - padding,
        y: rect.top - padding,
        width: rect.width + padding * 2,
        height: rect.height + padding * 2,
      });
      setAnimateGeo(animate);
    },
    [resolveTarget, steps, index, padding],
  );

  // Step entry: bring the target into view instantly, then spring the morph.
  React.useLayoutEffect(() => {
    if (!open) return;
    const el = resolveTarget(steps[index]);
    el?.scrollIntoView({ block: "nearest", inline: "nearest" });
    measure(true);
  }, [open, index, steps, resolveTarget, measure]);

  // Live geometry: rAF-throttled remeasure on scroll/resize, snapping.
  React.useEffect(() => {
    if (!open) return;
    const remeasure = () => {
      cancelAnimationFrame(rafId.current);
      rafId.current = requestAnimationFrame(() => measure(false));
    };
    window.addEventListener("scroll", remeasure, { capture: true, passive: true });
    window.addEventListener("resize", remeasure);
    return () => {
      cancelAnimationFrame(rafId.current);
      window.removeEventListener("scroll", remeasure, { capture: true });
      window.removeEventListener("resize", remeasure);
    };
  }, [open, measure]);

  // Card placement: measured after render, so the flip logic uses real size.
  React.useLayoutEffect(() => {
    if (!open) return;
    const card = cardRef.current;
    if (!card) return;
    const { width: cw, height: ch } = card.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    if (!hole) {
      setCardPos({ x: (vw - cw) / 2, y: (vh - ch) / 2 });
      return;
    }
    let y = hole.y + hole.height + CARD_GAP;
    if (y + ch > vh - VIEWPORT_MARGIN) y = hole.y - CARD_GAP - ch;
    if (y < VIEWPORT_MARGIN) y = VIEWPORT_MARGIN;
    const x = Math.max(
      VIEWPORT_MARGIN,
      Math.min(
        hole.x + hole.width / 2 - cw / 2,
        vw - cw - VIEWPORT_MARGIN,
      ),
    );
    setCardPos({ x, y });
  }, [open, hole, index]);

  // Focus: capture on open, land in the card (re-land on step change so the
  // new content announces), restore on close.
  React.useEffect(() => {
    if (open) {
      previousFocus.current = document.activeElement as HTMLElement | null;
    } else if (previousFocus.current) {
      previousFocus.current.focus({ preventScroll: true });
      previousFocus.current = null;
    }
  }, [open]);

  React.useEffect(() => {
    if (!open) return;
    const frame = requestAnimationFrame(() =>
      cardRef.current?.focus({ preventScroll: true }),
    );
    return () => cancelAnimationFrame(frame);
  }, [open, index]);

  const close = React.useCallback(
    (reason: "skip" | "complete") => {
      onOpenChange?.(false);
      if (reason === "complete") onComplete?.();
      else onSkip?.();
    },
    [onOpenChange, onComplete, onSkip],
  );

  const next = React.useCallback(() => {
    if (index >= steps.length - 1) close("complete");
    else setIndex(index + 1);
  }, [index, steps.length, close, setIndex]);

  const back = React.useCallback(() => {
    if (index > 0) setIndex(index - 1);
  }, [index, setIndex]);

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      close("skip");
    } else if (event.key === "ArrowRight") {
      event.preventDefault();
      next();
    } else if (event.key === "ArrowLeft") {
      event.preventDefault();
      back();
    } else if (event.key === "Tab") {
      // Minimal trap: cycle within the card's focusable controls.
      const card = cardRef.current;
      if (!card) return;
      const focusable = card.querySelectorAll<HTMLElement>(
        'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
      );
      if (focusable.length === 0) return;
      const first = focusable[0]!;
      const last = focusable[focusable.length - 1]!;
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  };

  const geoTransition =
    reducedMotion || !animateGeo
      ? ({ duration: 0 } as const)
      : ({ type: "spring", duration: 0.5, bounce: 0 } as const);

  if (!mounted || steps.length === 0) return null;

  return createPortal(
    <AnimatePresence>
      {open && (
        <motion.div
          key="product-tour"
          data-slot="product-tour"
          className="fixed inset-0 z-50"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{
            opacity: 0,
            transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
          }}
          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
          onKeyDown={onKeyDown}
        >
          {/* The dim layer: one full-viewport rect masked by the moving hole.
              Clicking it (even inside the cutout) skips the tour. */}
          <svg
            className="absolute inset-0 size-full"
            role="presentation"
            onClick={() => close("skip")}
          >
            <defs>
              <mask id={maskId} maskUnits="userSpaceOnUse">
                <rect width="100%" height="100%" fill="white" />
                {hole && (
                  <motion.rect
                    initial={false}
                    animate={{
                      x: hole.x,
                      y: hole.y,
                      width: hole.width,
                      height: hole.height,
                    }}
                    transition={geoTransition}
                    rx={radius}
                    fill="black"
                  />
                )}
              </mask>
            </defs>
            <rect
              width="100%"
              height="100%"
              mask={`url(#${maskId})`}
              className="fill-black/50 dark:fill-black/65"
            />
          </svg>

          <motion.div
            ref={cardRef}
            role="dialog"
            aria-modal="true"
            aria-labelledby={titleId}
            aria-describedby={currentStep?.body ? bodyId : undefined}
            tabIndex={-1}
            initial={false}
            animate={cardPos ? { x: cardPos.x, y: cardPos.y } : undefined}
            transition={geoTransition}
            style={{
              width: CARD_WIDTH,
              visibility: cardPos ? "visible" : "hidden",
            }}
            className="absolute top-0 left-0 max-w-[calc(100vw-2rem)] rounded-xl bg-popover p-4 text-popover-foreground shadow-overlay outline-none"
          >
            <div className="flex items-start gap-3">
              <div className="min-w-0 flex-1">
                <p className="text-[11px] font-medium text-muted-foreground tabular-nums">
                  Step {index + 1} of {steps.length}
                </p>
                <h2 id={titleId} className="mt-1 text-sm leading-5 font-semibold">
                  {currentStep?.title}
                </h2>
              </div>
              <button
                type="button"
                aria-label="Skip tour"
                onClick={() => close("skip")}
                className="pressable relative -m-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
              >
                <X className="size-3.5" />
              </button>
            </div>

            {currentStep?.body && (
              <div
                id={bodyId}
                className="mt-1.5 text-[13px] leading-5 text-muted-foreground"
              >
                {currentStep.body}
              </div>
            )}

            <div className="mt-4 flex items-center justify-between gap-3">
              <div aria-hidden className="flex items-center gap-1.5">
                {steps.map((_, dot) => (
                  <span
                    key={dot}
                    className={cn(
                      "size-1.5 rounded-full transition-[background-color,scale] duration-200 ease-[var(--ease-out)]",
                      dot === index
                        ? "scale-125 bg-primary"
                        : "bg-muted-foreground/30",
                    )}
                  />
                ))}
              </div>
              <div className="flex items-center gap-2">
                {index > 0 && (
                  <Button variant="ghost" size="sm" onClick={back}>
                    {backLabel}
                  </Button>
                )}
                <Button size="sm" onClick={next}>
                  {index >= steps.length - 1 ? doneLabel : nextLabel}
                </Button>
              </div>
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>,
    document.body,
  );
}