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.
npx shadcn@latest add @paragon/product-tourAlso installs: button
"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,
);
}