Guided Tour

PreviousNext

A guided tour component for onboarding users.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/guided-tour.jsx
"use client";

import * as React from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import {
	Popover,
	PopoverTrigger,
	PopoverContent,
} from "@/registry/optics/popover";
import { cn } from "@/registry/optics/lib/utils";
import { Button } from "@/registry/optics/button";
import { X } from "lucide-react";

const GuidedTourContext = React.createContext({
	activeTour: null,
	setActiveTour: () => {},
	currentStep: 0,
	setCurrentStep: () => {},
	steps: [],
	setSteps: () => {},
	totalSteps: 0,
});

function GuidedTourProvider({ children = null, ...props }) {
	const [activeTour, setActiveTour] = React.useState(null);
	const [currentStep, setCurrentStep] = React.useState(0);
	const [steps, setSteps] = React.useState([]);
	const [totalSteps, setTotalSteps] = React.useState(0);

	const contextValue = React.useMemo(
		() => ({
			activeTour,
			setActiveTour,
			currentStep,
			setCurrentStep,
			steps,
			setSteps,
			totalSteps,
			setTotalSteps,
		}),
		[activeTour, currentStep, steps, totalSteps],
	);

	return (
		<GuidedTourContext.Provider value={contextValue} {...props}>
			{children}
		</GuidedTourContext.Provider>
	);
}

function useGuidedTour() {
	const context = React.useContext(GuidedTourContext);
	if (!context) {
		throw new Error("useGuidedTour must be used within GuidedTourProvider");
	}
	return context;
}

const GuidedTourOverlay = React.forwardRef(({ className, ...props }, ref) => {
	const { activeTour } = useGuidedTour();

	if (!activeTour) return null;

	return (
		<div
			ref={ref}
			className={cn(
				"fixed inset-0 z-[100] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
				className,
			)}
			{...props}
		/>
	);
});
GuidedTourOverlay.displayName = "GuidedTourOverlay";

function GuidedTour({ children = null, ...props }) {
	return (
		<div data-slot="guided-tour" {...props}>
			{children}
		</div>
	);
}

function GuidedTourTrigger({
	tourId = "",
	children = null,
	className = "",
	render = null,
	...props
}) {
	const { onClick, ...restProps } = props;
	const { setActiveTour, setCurrentStep, setTotalSteps } = useGuidedTour();

	const handleClick = (event) => {
		if (onClick) {
			onClick(event);
		}

		if (event?.defaultPrevented) return;

		const tourSteps = document.querySelectorAll(
			`[data-tour="${tourId}"][data-step]`,
		);
		const sortedSteps = Array.from(tourSteps).sort((a, b) => {
			const stepA = parseInt(a.getAttribute("data-step")) || 0;
			const stepB = parseInt(b.getAttribute("data-step")) || 0;
			return stepA - stepB;
		});

		if (sortedSteps.length > 0) {
			setTotalSteps(sortedSteps.length);
			setCurrentStep(0);
			setActiveTour(tourId);

			// Scroll to the first element
			setTimeout(() => {
				sortedSteps[0]?.scrollIntoView({
					behavior: "smooth",
					block: "center",
				});
			}, 100);
		}
	};

	const triggerProps = {
		"data-slot": "guided-tour-trigger",
		onClick: handleClick,
		className: cn(!render && "cursor-pointer", className),
		type: render ? undefined : "button",
		...restProps,
	};

	if (render && React.isValidElement(render)) {
		return React.cloneElement(render, triggerProps);
	}

	return <Button {...triggerProps}>{children}</Button>;
}

const GuidedTourStep = React.forwardRef(
	(
		{
			tourId = "",
			step = 0,
			children = null,
			className = "",
			content = null,
			...props
		},
		ref,
	) => {
		const { activeTour, currentStep, setActiveTour, setCurrentStep } =
			useGuidedTour();
		const isActive = activeTour === tourId && currentStep === step - 1;
		const elementRef = React.useRef(null);
		const [open, setOpen] = React.useState(false);

		React.useEffect(() => {
			if (isActive && elementRef.current) {
				setOpen(true);
				// Scroll to the active element
				setTimeout(() => {
					elementRef.current?.scrollIntoView({
						behavior: "smooth",
						block: "center",
					});
				}, 100);
			} else {
				setOpen(false);
			}
		}, [isActive]);

		const handleOpenChange = (newOpen) => {
			setOpen(newOpen);
			// If the popover is closed by user interaction (click outside), deactivate the tour
			if (!newOpen && isActive && activeTour === tourId) {
				// Capture current values to check after timeout
				const currentTourId = activeTour;
				const currentStepValue = currentStep;
				// Use a small timeout to allow step transitions to complete
				// If we're transitioning to another step, the tour will remain active
				setTimeout(() => {
					// Only deactivate if the tour is still active and we're still on this step
					// This means the user closed the popover manually, not by navigating
					if (
						activeTour === currentTourId &&
						currentStep === currentStepValue
					) {
						setActiveTour(null);
						setCurrentStep(0);
					}
				}, 100);
			}
		};

		const clonedElement = React.cloneElement(children, {
			ref: (node) => {
				elementRef.current = node;
				if (typeof children.ref === "function") {
					children.ref(node);
				} else if (ref) {
					if (typeof ref === "function") {
						ref(node);
					} else {
						ref.current = node;
					}
				}
			},
			"data-tour": tourId,
			"data-step": step,
			className: cn(
				className,
				isActive && "relative z-[101]",
				children.props?.className,
			),
			...props,
		});
		const isNativeButton =
			typeof clonedElement.type === "string" &&
			clonedElement.type.toLowerCase() === "button";

		if (!isActive || !content) {
			return clonedElement;
		}

		return (
			<Popover
				open={open}
				onOpenChange={(nextOpen) => handleOpenChange(nextOpen)}
			>
				<PopoverTrigger nativeButton={isNativeButton} render={clonedElement} />
				<GuidedTourStepContent>{content}</GuidedTourStepContent>
			</Popover>
		);
	},
);
GuidedTourStep.displayName = "GuidedTourStep";

function GuidedTourStepContent({ children }) {
	const { currentStep, setCurrentStep, setActiveTour, totalSteps } =
		useGuidedTour();

	const handleNext = () => {
		if (currentStep < totalSteps - 1) {
			setCurrentStep(currentStep + 1);
		} else {
			handleFinish();
		}
	};

	const handleBack = () => {
		if (currentStep > 0) {
			setCurrentStep(currentStep - 1);
		}
	};

	const handleSkip = () => {
		setActiveTour(null);
		setCurrentStep(0);
	};

	const handleFinish = () => {
		setActiveTour(null);
		setCurrentStep(0);
	};

	const isLastStep = currentStep === totalSteps - 1;
	const isFirstStep = currentStep === 0;

	return (
		<GuidedTourContent
			onSkip={handleSkip}
			onNext={handleNext}
			onBack={handleBack}
			onFinish={handleFinish}
			isLastStep={isLastStep}
			isFirstStep={isFirstStep}
		>
			{children}
		</GuidedTourContent>
	);
}

function GuidedTourContent({
	children = null,
	className = "",
	onSkip = undefined,
	onNext = undefined,
	onBack = undefined,
	onFinish = undefined,
	isLastStep = false,
	isFirstStep = false,
	align = "start",
	alignOffset = 0,
	side = "bottom",
	sideOffset = 8,
	...props
}) {
	const { currentStep, totalSteps } = useGuidedTour();

	return (
		<PopoverContent
			align={align}
			alignOffset={alignOffset}
			side={side}
			sideOffset={sideOffset}
			positionerClassName="z-[102]"
			className={cn(
				"w-80 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95",
				className,
			)}
			data-slot="guided-tour-content"
			{...props}
		>
			<div className="flex flex-col gap-4">
				<div className="flex items-start justify-between gap-2">
					<div className="flex-1">{children}</div>
					{onSkip && (
						<PopoverPrimitive.Close
							render={
								<button
									type="button"
									onClick={onSkip}
									className="rounded-sm opacity-70 hover:opacity-100 transition-opacity p-1 -mt-1 -mr-1"
									aria-label="Close"
								>
									<X className="h-4 w-4" />
								</button>
							}
						/>
					)}
				</div>

				<div className="flex items-center justify-between gap-2">
					<div className="text-xs text-muted-foreground">
						Step {currentStep + 1} of {totalSteps}
					</div>
					<div className="flex items-center gap-2">
						{onSkip && (
							<Button variant="ghost" size="sm" onClick={onSkip} type="button">
								Skip
							</Button>
						)}
						{onBack && !isFirstStep && (
							<Button
								variant="outline"
								size="sm"
								onClick={onBack}
								type="button"
							>
								Back
							</Button>
						)}
						{isLastStep ? (
							<Button
								variant="default"
								size="sm"
								onClick={onFinish}
								type="button"
							>
								Finish
							</Button>
						) : (
							<Button
								variant="default"
								size="sm"
								onClick={onNext}
								type="button"
							>
								Next
							</Button>
						)}
					</div>
				</div>
			</div>
		</PopoverContent>
	);
}

// GuidedTourPopover is now deprecated - use GuidedTourStep with content prop instead
function GuidedTourPopover({ children = null, ...props }) {
	const { activeTour, currentStep, setCurrentStep, setActiveTour, totalSteps } =
		useGuidedTour();
	const [open, setOpen] = React.useState(false);
	const [currentElement, setCurrentElement] = React.useState(null);

	React.useEffect(() => {
		if (activeTour) {
			const element = document.querySelector(
				`[data-tour="${activeTour}"][data-step="${currentStep + 1}"]`,
			);
			if (element) {
				setCurrentElement(element);
				setOpen(true);
				// Scroll to the element
				setTimeout(() => {
					element.scrollIntoView({
						behavior: "smooth",
						block: "center",
					});
				}, 100);
			} else {
				setOpen(false);
				setCurrentElement(null);
			}
		} else {
			setOpen(false);
			setCurrentElement(null);
		}
	}, [activeTour, currentStep]);

	const handleNext = () => {
		if (currentStep < totalSteps - 1) {
			setCurrentStep(currentStep + 1);
		} else {
			handleFinish();
		}
	};

	const handleBack = () => {
		if (currentStep > 0) {
			setCurrentStep(currentStep - 1);
		}
	};

	const handleSkip = () => {
		setActiveTour(null);
		setCurrentStep(0);
		setOpen(false);
		setCurrentElement(null);
	};

	const handleFinish = () => {
		setActiveTour(null);
		setCurrentStep(0);
		setOpen(false);
		setCurrentElement(null);
	};

	const isLastStep = currentStep === totalSteps - 1;
	const isFirstStep = currentStep === 0;

	if (!activeTour || !open || !currentElement) return null;

	return (
		<Popover
			open={open}
			onOpenChange={(nextOpen) => setOpen(nextOpen)}
			{...props}
		>
			<PopoverTrigger
				nativeButton={false}
				render={
					<div
						style={{
							position: "absolute",
							left: currentElement.getBoundingClientRect().left,
							top: currentElement.getBoundingClientRect().top,
							width: currentElement.getBoundingClientRect().width,
							height: currentElement.getBoundingClientRect().height,
							pointerEvents: "none",
						}}
					/>
				}
			/>
			<GuidedTourContent
				onSkip={handleSkip}
				onNext={handleNext}
				onBack={handleBack}
				onFinish={handleFinish}
				isLastStep={isLastStep}
				isFirstStep={isFirstStep}
			>
				{children}
			</GuidedTourContent>
		</Popover>
	);
}

export {
	GuidedTourProvider,
	GuidedTour,
	GuidedTourTrigger,
	GuidedTourStep,
	GuidedTourOverlay,
	GuidedTourContent,
	GuidedTourPopover,
	useGuidedTour,
};

Installation

npx shadcn@latest add @optics/guided-tour

Usage

import { GuidedTour } from "@/components/guided-tour"
<GuidedTour />