"use client";
import * as React from "react";
import { AnimatePresence, motion, useSpring } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
type Placement = "auto" | "top" | "bottom" | "left" | "right";
export interface ProductTourStep {
/** A CSS selector (resolved inside `root`), a ref, or a function returning the element. Omit for a centred step. */
target?: string | React.RefObject<HTMLElement | null> | (() => HTMLElement | null);
title: string;
body: React.ReactNode;
/** Overrides the tour's placement for this step. */
placement?: Placement;
}
export interface ProductTourProps {
steps: ProductTourStep[];
/** Whether the tour is running (controlled). */
open: boolean;
/** Called with false when the tour is finished, skipped or dismissed with Escape. */
onOpenChange: (open: boolean) => void;
/** Step shown when the tour opens. */
defaultStep?: number;
/** Called whenever the step changes. */
onStepChange?: (index: number) => void;
/** Called when the last step's Done button is pressed. */
onFinish?: () => void;
/** Scope for selector targets. Defaults to the document. */
root?: React.RefObject<HTMLElement | null>;
/**
* true: a modal <dialog> over the whole viewport (focus trapped, page inert).
* false: the overlay fills the nearest positioned ancestor and the popover is a non-modal dialog.
*/
modal?: boolean;
/** Space between the target and the spotlight edge, in px. */
spotlightPadding?: number;
/** Corner radius of the spotlight, in px. */
spotlightRadius?: number;
/** Opacity of the dimmed overlay (0–1). */
overlayOpacity?: number;
/** Preferred popover side; "auto" picks the side with room. */
placement?: Placement;
/** Show progress dots in the popover. */
showProgress?: boolean;
/** Move focus into the popover when the tour opens. Turn off only for non-interactive previews. */
autoFocus?: boolean;
/** Spring for the spotlight and popover moving between targets. */
spring?: Spring;
/** Spotlight ring, progress and primary button colour. */
accent?: string;
/** Button labels. */
labels?: Partial<Record<"next" | "back" | "done" | "skip", string>>;
className?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const MARGIN = 12; // px kept between the popover and the bounds
const OFFSET = 12; // px between the spotlight and the popover
type Box = { left: number; top: number; width: number; height: number };
function resolveTarget(target: ProductTourStep["target"], root: HTMLElement | null): HTMLElement | null {
if (!target) return null;
if (typeof target === "function") return target();
if (typeof target === "string") return (root ?? document).querySelector<HTMLElement>(target);
return target.current;
}
function isScrollable(el: HTMLElement) {
const style = getComputedStyle(el);
const y = /(auto|scroll|overlay)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 1;
const x = /(auto|scroll|overlay)/.test(style.overflowX) && el.scrollWidth > el.clientWidth + 1;
return x || y;
}
/** Distance to scroll so `inner` sits inside `outer` with a margin (0 when it already does). */
function delta(innerStart: number, innerEnd: number, outerStart: number, outerEnd: number, margin: number) {
const size = innerEnd - innerStart;
if (size + margin * 2 > outerEnd - outerStart) return innerStart - outerStart - margin; // too big: align the start
if (innerStart < outerStart + margin) return innerStart - outerStart - margin;
if (innerEnd > outerEnd - margin) return innerEnd - outerEnd + margin;
return 0;
}
/** Brings the target into view through every scrollable ancestor, then the window. Never uses scrollIntoView. */
function revealTarget(el: HTMLElement, smooth: boolean, margin: number) {
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
let node = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
if (isScrollable(node)) {
const outer = node.getBoundingClientRect();
const inner = el.getBoundingClientRect();
const top = delta(inner.top, inner.bottom, outer.top, outer.bottom, margin);
const left = delta(inner.left, inner.right, outer.left, outer.right, margin);
if (top || left) node.scrollBy({ top, left, behavior });
}
node = node.parentElement;
}
const rect = el.getBoundingClientRect();
// Leave extra room so the popover can sit above or below the target.
const top = delta(rect.top, rect.bottom, 0, window.innerHeight, Math.min(160, window.innerHeight / 4));
if (top) window.scrollBy({ top, behavior });
}
/** Picks a side for the popover that fits inside the bounds, then clamps it. */
function placePopover(target: Box, pop: { width: number; height: number }, bounds: { width: number; height: number }, preferred: Placement) {
const cx = target.left + target.width / 2;
const cy = target.top + target.height / 2;
const options = {
bottom: { x: cx - pop.width / 2, y: target.top + target.height + OFFSET, room: bounds.height - (target.top + target.height) - OFFSET - pop.height },
top: { x: cx - pop.width / 2, y: target.top - OFFSET - pop.height, room: target.top - OFFSET - pop.height },
right: { x: target.left + target.width + OFFSET, y: cy - pop.height / 2, room: bounds.width - (target.left + target.width) - OFFSET - pop.width },
left: { x: target.left - OFFSET - pop.width, y: cy - pop.height / 2, room: target.left - OFFSET - pop.width },
};
const order: (keyof typeof options)[] = ["bottom", "top", "right", "left"];
if (preferred !== "auto") order.unshift(preferred);
const fit = order.find((side) => options[side].room >= MARGIN) ?? order.reduce((a, b) => (options[b].room > options[a].room ? b : a));
const choice = options[fit];
return {
x: Math.round(Math.max(MARGIN, Math.min(bounds.width - pop.width - MARGIN, choice.x))),
y: Math.round(Math.max(MARGIN, Math.min(bounds.height - pop.height - MARGIN, choice.y))),
};
}
/**
* An onboarding tour that dims the page, cuts a spotlight around each target and anchors a step card next to
* it. The spotlight and card spring between targets, targets are scrolled into view through their scroll
* containers, and the card is a labelled dialog that takes focus, closes on Escape and returns focus.
*/
export function ProductTour({
steps,
open,
onOpenChange,
defaultStep = 0,
onStepChange,
onFinish,
root,
modal = true,
spotlightPadding = 8,
spotlightRadius = 12,
overlayOpacity = 0.6,
placement = "auto",
showProgress = true,
autoFocus = true,
spring = { stiffness: 260, damping: 30, mass: 1 },
accent = "#FF7448",
labels,
className = "",
}: ProductTourProps) {
const uid = React.useId();
const maskId = `${uid.replace(/[^a-zA-Z0-9_-]/g, "")}-tour-mask`;
const titleId = `${uid}-title`;
const bodyId = `${uid}-body`;
const countId = `${uid}-count`;
const reduceMotion = usePrefersReducedMotion();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const layerRef = React.useRef<HTMLDivElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const primaryRef = React.useRef<HTMLButtonElement>(null);
const targetRef = React.useRef<HTMLElement | null>(null);
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const animateUntilRef = React.useRef(0);
const shownRef = React.useRef(false);
const [step, setStep] = React.useState(defaultStep);
// Restart from defaultStep every time the tour opens.
const [wasOpen, setWasOpen] = React.useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setStep(defaultStep);
}
const index = Math.max(0, Math.min(steps.length - 1, step));
const current = steps[index];
const last = index === steps.length - 1;
const springConfig = reduceMotion ? { stiffness: 1000, damping: 100, mass: 0.1 } : spring;
const sx = useSpring(0, springConfig);
const sy = useSpring(0, springConfig);
const sw = useSpring(0, springConfig);
const sh = useSpring(0, springConfig);
const px = useSpring(0, springConfig);
const py = useSpring(0, springConfig);
const goTo = (next: number) => {
const clamped = Math.max(0, Math.min(steps.length - 1, next));
if (clamped === index) return;
setStep(clamped);
onStepChange?.(clamped);
};
const close = () => onOpenChange(false);
const finish = () => {
onFinish?.();
close();
};
/** Measures the target and popover and moves the spotlight and card. "jump" skips the spring (scroll tracking). */
const measure = React.useCallback(
(mode: "jump" | "animate") => {
const layer = layerRef.current;
const pop = popoverRef.current;
if (!layer || !pop) return;
const bounds = layer.getBoundingClientRect();
const el = targetRef.current;
const pad = spotlightPadding;
let box: Box;
if (el && el.isConnected) {
const r = el.getBoundingClientRect();
box = { left: r.left - bounds.left - pad, top: r.top - bounds.top - pad, width: r.width + pad * 2, height: r.height + pad * 2 };
} else {
// No target: an empty spotlight in the middle, with the card centred.
box = { left: bounds.width / 2, top: bounds.height / 2, width: 0, height: 0 };
}
const size = { width: pop.offsetWidth, height: pop.offsetHeight };
const spot = el
? placePopover(box, size, bounds, current?.placement ?? placement)
: { x: Math.round((bounds.width - size.width) / 2), y: Math.round((bounds.height - size.height) / 2) };
const apply = (value: ReturnType<typeof useSpring>, to: number) => {
const v = Math.round(to * 100) / 100;
if (mode === "jump") value.jump(v);
else value.set(v);
};
apply(sx, box.left);
apply(sy, box.top);
apply(sw, box.width);
apply(sh, box.height);
apply(px, spot.x);
apply(py, spot.y);
},
[current?.placement, placement, spotlightPadding, sx, sy, sw, sh, px, py],
);
// Open / close the native dialog (modal mode) and return focus afterwards.
React.useEffect(() => {
const dialog = dialogRef.current;
if (open) {
if (!returnFocusRef.current) {
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
if (modal && dialog && !dialog.open) dialog.showModal();
return;
}
if (dialog?.open) dialog.close();
shownRef.current = false;
const back = returnFocusRef.current;
returnFocusRef.current = null;
if (back && back.isConnected && autoFocus) back.focus({ preventScroll: true });
}, [open, modal, autoFocus]);
// On every step: resolve the target, scroll it into view, then spring (or jump, on first show) to it.
React.useEffect(() => {
if (!open) return;
const el = resolveTarget(current?.target, root?.current ?? null);
targetRef.current = el;
const first = !shownRef.current;
shownRef.current = true;
if (el) revealTarget(el, !reduceMotion && !first, spotlightPadding + 16);
// While a smooth scroll settles, keep springing rather than jumping.
animateUntilRef.current = first || reduceMotion ? 0 : performance.now() + 900;
measure(first ? "jump" : "animate");
if (autoFocus) primaryRef.current?.focus({ preventScroll: true });
const ro = new ResizeObserver(() => measure(performance.now() < animateUntilRef.current ? "animate" : "jump"));
if (el) ro.observe(el);
if (popoverRef.current) ro.observe(popoverRef.current);
return () => ro.disconnect();
}, [open, index, current, root, reduceMotion, spotlightPadding, autoFocus, measure]);
// Track scrolling anywhere (capture) and resizes, at most once per frame.
React.useEffect(() => {
if (!open) return;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => measure(performance.now() < animateUntilRef.current ? "animate" : "jump"));
};
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, { capture: true });
window.removeEventListener("resize", schedule);
};
}, [open, measure]);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape" && !modal) {
event.preventDefault();
close();
} else if (event.key === "ArrowRight" && !last) {
goTo(index + 1);
} else if (event.key === "ArrowLeft" && index > 0) {
goTo(index - 1);
}
};
const text = { next: "Next", back: "Back", done: "Done", skip: "Skip tour", ...labels };
const fade = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: [0.16, 1, 0.3, 1] as const };
const layer = open && current && (
<div
ref={layerRef}
className={`${modal ? "fixed" : "absolute"} inset-0 overflow-hidden ${className}`}
style={{ ["--pt-accent" as string]: accent }}
onKeyDown={onKeyDown}
>
<motion.svg
aria-hidden="true"
className="absolute inset-0 h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={fade}
>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<motion.rect x={sx} y={sy} width={sw} height={sh} rx={spotlightRadius} fill="black" />
</mask>
</defs>
<rect width="100%" height="100%" mask={`url(#${maskId})`} className="fill-zinc-950" fillOpacity={overlayOpacity} />
<motion.rect
x={sx}
y={sy}
width={sw}
height={sh}
rx={spotlightRadius}
fill="none"
stroke="var(--pt-accent)"
strokeWidth={2}
className={current.target ? "" : "hidden"}
/>
</motion.svg>
<motion.div
ref={popoverRef}
role={modal ? undefined : "dialog"}
aria-modal={modal ? undefined : false}
aria-labelledby={modal ? undefined : `${countId} ${titleId}`}
aria-describedby={modal ? undefined : bodyId}
className="absolute left-0 top-0 w-[min(320px,calc(100%-24px))]"
style={{ x: px, y: py }}
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={fade}
>
<div className="rounded-2xl border border-zinc-900/10 bg-white p-4 text-zinc-900 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.45),0_2px_8px_rgba(0,0,0,0.06)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-[0_24px_60px_-16px_rgba(0,0,0,0.9)]">
<div className="flex items-center justify-between gap-3">
<p id={countId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
Step {index + 1} of {steps.length}
</p>
{!last && (
<button
type="button"
onClick={close}
className="rounded-md px-1.5 py-0.5 text-[12px] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)] dark:text-zinc-400 dark:hover:text-zinc-100"
>
{text.skip}
</button>
)}
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={index}
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }}
transition={{ duration: reduceMotion ? 0 : 0.16 }}
>
<h2 id={titleId} className="mt-2 text-[15px] font-semibold leading-snug tracking-tight">
{current.title}
</h2>
<div id={bodyId} className="mt-1.5 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-300">
{current.body}
</div>
</motion.div>
</AnimatePresence>
<div className="mt-4 flex items-center justify-between gap-3">
{showProgress ? (
<div aria-hidden="true" className="flex items-center gap-1">
{steps.map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-[width,background-color] duration-300 ${
i === index ? "w-4 bg-[var(--pt-accent)]" : i < index ? "w-1.5 bg-zinc-400 dark:bg-zinc-500" : "w-1.5 bg-zinc-900/15 dark:bg-white/15"
}`}
/>
))}
</div>
) : (
<span />
)}
<div className="flex items-center gap-1.5">
{index > 0 && (
<button
type="button"
onClick={() => goTo(index - 1)}
className="h-8 rounded-lg border border-zinc-900/10 px-3 text-[13px] font-medium text-zinc-700 outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)] dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/[0.06]"
>
{text.back}
</button>
)}
<button
ref={primaryRef}
type="button"
onClick={() => (last ? finish() : goTo(index + 1))}
className="h-8 rounded-lg bg-[var(--pt-accent)] px-3.5 text-[13px] font-medium text-zinc-950 outline-hidden transition-[filter] hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)]"
>
{last ? text.done : text.next}
</button>
</div>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
Step {index + 1} of {steps.length}: {current.title}
</p>
</motion.div>
</div>
);
if (!modal) return layer || null;
return (
<dialog
ref={dialogRef}
aria-labelledby={`${countId} ${titleId}`}
aria-describedby={bodyId}
onCancel={(event) => {
// Escape: keep React state as the source of truth.
event.preventDefault();
close();
}}
className="m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent"
>
{layer}
</dialog>
);
}