Tour
A guided tour component that highlights elements and provides step-by-step instructions to help users learn about your application.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Tour,
TourArrow,
TourClose,
TourDescription,
TourFooter,
TourHeader,
TourNext,
TourPortal,
TourPrev,
TourSpotlight,
TourSpotlightRing,
TourStep,
TourStepCounter,
TourTitle,
} from "@/components/ui/tour";
export function TourDemo() {
const [open, setOpen] = React.useState(false);
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-8 p-8">
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-1">
<h1 id="welcome-title" className="font-bold text-2xl">
Learn to Skate
</h1>
<p className="text-center text-muted-foreground">
Start the tour to learn essential tricks.
</p>
</div>
<Button id="start-tour-btn" onClick={() => setOpen(true)}>
Start Tour
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
<div id="feature-1" className="rounded-lg border p-4 text-center">
<h3 className="font-semibold">Ollie</h3>
<p className="text-muted-foreground text-sm">
Foundation of all tricks
</p>
</div>
<div id="feature-2" className="rounded-lg border p-4 text-center">
<h3 className="font-semibold">Kickflip</h3>
<p className="text-muted-foreground text-sm">Your first flip trick</p>
</div>
<div id="feature-3" className="rounded-lg border p-4 text-center">
<h3 className="font-semibold">900</h3>
<p className="text-muted-foreground text-sm">Two and a half spins</p>
</div>
</div>
<Tour
open={open}
onOpenChange={setOpen}
stepFooter={
<TourFooter>
<div className="flex w-full items-center justify-between">
<TourStepCounter />
<div className="flex gap-2">
<TourPrev />
<TourNext />
</div>
</div>
</TourFooter>
}
>
<TourPortal>
<TourSpotlight />
<TourSpotlightRing />
<TourStep target="#welcome-title" side="bottom" align="center">
<TourHeader>
<TourTitle>Welcome</TourTitle>
<TourDescription>
Learn the essential tricks every skater needs to know.
</TourDescription>
</TourHeader>
<TourClose />
</TourStep>
<TourStep target="#feature-1" side="top" align="center">
<TourArrow />
<TourHeader>
<TourTitle>Ollie</TourTitle>
<TourDescription>
Pop the tail, slide your front foot up, and level out in the
air.
</TourDescription>
</TourHeader>
<TourClose />
</TourStep>
<TourStep target="#feature-2" side="top" align="center">
<TourArrow />
<TourHeader>
<TourTitle>Kickflip</TourTitle>
<TourDescription>
Flick your front foot off the edge to spin the board 360
degrees.
</TourDescription>
</TourHeader>
<TourClose />
</TourStep>
<TourStep target="#feature-3" side="top" align="center" required>
<TourArrow />
<TourHeader>
<TourTitle>900</TourTitle>
<TourDescription>
Two and a half aerial spins. This step is required.
</TourDescription>
</TourHeader>
<TourClose />
</TourStep>
</TourPortal>
</Tour>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://diceui.com/r/tour"Manual
Install the following dependencies:
npm install @floating-ui/react-dom @radix-ui/react-slot lucide-reactCopy and paste the following code into your project.
"use client";
import {
autoUpdate,
flip,
hide,
limitShift,
type Middleware,
offset,
arrow as onArrow,
type Placement,
shift,
useFloating,
} from "@floating-ui/react-dom";
import { Slot } from "@radix-ui/react-slot";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Button } from "@/components/ui/button";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const ROOT_NAME = "Tour";
const PORTAL_NAME = "TourPortal";
const STEP_NAME = "TourStep";
const ARROW_NAME = "TourArrow";
const HEADER_NAME = "TourHeader";
const TITLE_NAME = "TourTitle";
const DESCRIPTION_NAME = "TourDescription";
const CLOSE_NAME = "TourClose";
const PREV_NAME = "TourPrev";
const NEXT_NAME = "TourNext";
const SKIP_NAME = "TourSkip";
const FOOTER_NAME = "TourFooter";
const POINTER_DOWN_OUTSIDE = "tour.pointerDownOutside";
const INTERACT_OUTSIDE = "tour.interactOutside";
const OPEN_AUTO_FOCUS = "tour.openAutoFocus";
const CLOSE_AUTO_FOCUS = "tour.closeAutoFocus";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const;
const ALIGN_OPTIONS = ["start", "center", "end"] as const;
const DEFAULT_ALIGN_OFFSET = 0;
const DEFAULT_SIDE_OFFSET = 16;
const DEFAULT_SPOTLIGHT_PADDING = 4;
type Side = (typeof SIDE_OPTIONS)[number];
type Align = (typeof ALIGN_OPTIONS)[number];
type Direction = "ltr" | "rtl";
interface ScrollOffset {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
type Boundary = Element | null;
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type StepElement = React.ComponentRef<typeof TourStep>;
type CloseElement = React.ComponentRef<typeof TourClose>;
type PrevElement = React.ComponentRef<typeof TourPrev>;
type NextElement = React.ComponentRef<typeof TourNext>;
type SkipElement = React.ComponentRef<typeof TourSkip>;
type FooterElement = React.ComponentRef<typeof TourFooter>;
const OPPOSITE_SIDE: Record<Side, Side> = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
};
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/focus-guards/src/focus-guards.tsx
*/
let focusGuardCount = 0;
function createFocusGuard() {
const element = document.createElement("span");
element.setAttribute("data-tour-focus-guard", "");
element.tabIndex = 0;
element.style.outline = "none";
element.style.opacity = "0";
element.style.position = "fixed";
element.style.pointerEvents = "none";
return element;
}
function useFocusGuards() {
React.useEffect(() => {
const edgeGuards = document.querySelectorAll("[data-tour-focus-guard]");
document.body.insertAdjacentElement(
"afterbegin",
edgeGuards[0] ?? createFocusGuard(),
);
document.body.insertAdjacentElement(
"beforeend",
edgeGuards[1] ?? createFocusGuard(),
);
focusGuardCount++;
return () => {
if (focusGuardCount === 1) {
const guards = document.querySelectorAll("[data-tour-focus-guard]");
for (const node of guards) {
node.remove();
}
}
focusGuardCount--;
};
}, []);
}
function useFocusTrap(
containerRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
tourOpen: boolean,
onOpenAutoFocus?: (event: OpenAutoFocusEvent) => void,
onCloseAutoFocus?: (event: CloseAutoFocusEvent) => void,
) {
const lastFocusedElementRef = React.useRef<HTMLElement | null>(null);
const onOpenAutoFocusRef = useAsRef(onOpenAutoFocus);
const onCloseAutoFocusRef = useAsRef(onCloseAutoFocus);
const tourOpenRef = useAsRef(tourOpen);
React.useEffect(() => {
if (!enabled) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocusedElement =
document.activeElement as HTMLElement | null;
function getTabbableCandidates() {
if (!container) return [];
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node: Element) => {
const element = node as HTMLElement;
const isHiddenInput =
element.tagName === "INPUT" &&
(element as HTMLInputElement).type === "hidden";
if (element.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
return element.tabIndex >= 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
},
);
while (walker.nextNode()) {
nodes.push(walker.currentNode as HTMLElement);
}
return nodes;
}
function getTabbableEdges() {
const candidates = getTabbableCandidates();
const first = candidates[0];
const last = candidates[candidates.length - 1];
return [first, last] as const;
}
function onFocusIn(event: FocusEvent) {
if (!container) return;
const target = event.target as HTMLElement | null;
if (container.contains(target)) {
lastFocusedElementRef.current = target;
} else {
const elementToFocus =
lastFocusedElementRef.current ?? getTabbableCandidates()[0];
elementToFocus?.focus({ preventScroll: true });
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey)
return;
const [first, last] = getTabbableEdges();
const hasTabbableElements = first && last;
if (!hasTabbableElements) {
if (document.activeElement === container) event.preventDefault();
return;
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus({ preventScroll: true });
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus({ preventScroll: true });
}
}
const openAutoFocusEvent = new CustomEvent(OPEN_AUTO_FOCUS, EVENT_OPTIONS);
if (onOpenAutoFocusRef.current) {
container.addEventListener(
OPEN_AUTO_FOCUS,
onOpenAutoFocusRef.current as EventListener,
{ once: true },
);
}
container.dispatchEvent(openAutoFocusEvent);
if (!openAutoFocusEvent.defaultPrevented) {
const tabbableCandidates = getTabbableCandidates();
if (tabbableCandidates.length > 0) {
tabbableCandidates[0]?.focus({ preventScroll: true });
} else {
container.focus({ preventScroll: true });
}
}
document.addEventListener("focusin", onFocusIn);
container.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("focusin", onFocusIn);
container.removeEventListener("keydown", onKeyDown);
if (!tourOpenRef.current) {
setTimeout(() => {
const closeAutoFocusEvent = new CustomEvent(
CLOSE_AUTO_FOCUS,
EVENT_OPTIONS,
);
if (onCloseAutoFocusRef.current) {
container.addEventListener(
CLOSE_AUTO_FOCUS,
onCloseAutoFocusRef.current as EventListener,
{ once: true },
);
}
container.dispatchEvent(closeAutoFocusEvent);
if (!closeAutoFocusEvent.defaultPrevented) {
if (
previouslyFocusedElement &&
document.body.contains(previouslyFocusedElement)
) {
previouslyFocusedElement.focus({ preventScroll: true });
}
}
if (onCloseAutoFocusRef.current) {
container.removeEventListener(
CLOSE_AUTO_FOCUS,
onCloseAutoFocusRef.current as EventListener,
);
}
}, 0);
}
};
}, [
containerRef,
enabled,
onOpenAutoFocusRef,
onCloseAutoFocusRef,
tourOpenRef,
]);
}
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
function getDataState(open: boolean): string {
return open ? "open" : "closed";
}
interface StepData {
target: string | React.RefObject<HTMLElement> | HTMLElement;
align?: Align;
alignOffset?: number;
side?: Side;
sideOffset?: number;
collisionBoundary?: Boundary | Boundary[];
collisionPadding?: number | Partial<Record<Side, number>>;
arrowPadding?: number;
sticky?: "partial" | "always";
hideWhenDetached?: boolean;
avoidCollisions?: boolean;
onStepEnter?: () => void;
onStepLeave?: () => void;
required?: boolean;
}
interface StoreState {
open: boolean;
value: number;
steps: StepData[];
maskPath: string;
spotlightRect: { x: number; y: number; width: number; height: number } | null;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(
key: K,
value: StoreState[K],
opts?: unknown,
) => void;
notify: () => void;
addStep: (stepData: StepData) => { id: string; index: number };
removeStep: (id: string) => void;
}
function useStore<T>(selector: (state: StoreState) => T): T {
const store = useStoreContext("useStore");
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
function getTargetElement(
target: string | React.RefObject<HTMLElement> | HTMLElement,
): HTMLElement | null {
if (typeof target === "string") {
return document.querySelector(target);
}
if (target && "current" in target) {
return target.current;
}
if (target instanceof HTMLElement) {
return target;
}
return null;
}
function getDefaultScrollBehavior(): ScrollBehavior {
if (typeof window === "undefined") return "smooth";
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth";
}
function onScrollToElement(
element: HTMLElement,
scrollBehavior: ScrollBehavior = getDefaultScrollBehavior(),
scrollOffset?: ScrollOffset,
) {
const offset: Required<ScrollOffset> = {
top: 100,
bottom: 100,
left: 0,
right: 0,
...scrollOffset,
};
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const isInViewport =
rect.top >= offset.top &&
rect.bottom <= viewportHeight - offset.bottom &&
rect.left >= offset.left &&
rect.right <= viewportWidth - offset.right;
if (!isInViewport) {
const elementTop = rect.top + window.scrollY;
const scrollTop = elementTop - offset.top;
window.scrollTo({
top: Math.max(0, scrollTop),
behavior: scrollBehavior,
});
}
}
function getSideAndAlignFromPlacement(placement: Placement): [Side, Align] {
const [side, align = "center"] = placement.split("-") as [Side, Align?];
return [side, align];
}
function getPlacement(side: Side, align: Align): Placement {
if (align === "center") {
return side as Placement;
}
return `${side}-${align}` as Placement;
}
function updateMask(
store: Store,
targetElement: HTMLElement,
padding: number = DEFAULT_SPOTLIGHT_PADDING,
) {
const clientRect = targetElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const x = Math.max(0, clientRect.left - padding);
const y = Math.max(0, clientRect.top - padding);
const width = Math.min(viewportWidth - x, clientRect.width + padding * 2);
const height = Math.min(viewportHeight - y, clientRect.height + padding * 2);
const path = `polygon(0% 0%, 0% 100%, ${x}px 100%, ${x}px ${y}px, ${x + width}px ${y}px, ${x + width}px ${y + height}px, ${x}px ${y + height}px, ${x}px 100%, 100% 100%, 100% 0%)`;
store.setState("maskPath", path);
store.setState("spotlightRect", { x, y, width, height });
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface TourContextValue {
dir: Direction;
alignOffset: number;
sideOffset: number;
spotlightPadding: number;
dismissible: boolean;
modal: boolean;
stepFooter?: React.ReactElement;
onPointerDownOutside?: (event: PointerDownOutsideEvent) => void;
onInteractOutside?: (event: InteractOutsideEvent) => void;
onOpenAutoFocus?: (event: OpenAutoFocusEvent) => void;
onCloseAutoFocus?: (event: CloseAutoFocusEvent) => void;
}
const TourContext = React.createContext<TourContextValue | null>(null);
function useTourContext(consumerName: string) {
const context = React.useContext(TourContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface StepContextValue {
arrowX?: number;
arrowY?: number;
placedAlign: Align;
placedSide: Side;
shouldHideArrow: boolean;
onArrowChange: (arrow: HTMLSpanElement | null) => void;
onFooterChange: (footer: FooterElement | null) => void;
}
const StepContext = React.createContext<StepContextValue | null>(null);
function useStepContext(consumerName: string) {
const context = React.useContext(StepContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${STEP_NAME}\``);
}
return context;
}
const DefaultFooterContext = React.createContext(false);
interface PortalContextValue {
portal: HTMLElement | null;
onPortalChange: (node: HTMLElement | null) => void;
}
const PortalContext = React.createContext<PortalContextValue | null>(null);
function usePortalContext(consumerName: string) {
const context = React.useContext(PortalContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useScrollLock(enabled: boolean) {
React.useEffect(() => {
if (!enabled) return;
const originalStyle = window.getComputedStyle(document.body).overflow;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalStyle;
document.body.style.paddingRight = "";
};
}, [enabled]);
}
type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>;
type InteractOutsideEvent = CustomEvent<{
originalEvent: PointerEvent | FocusEvent;
}>;
type OpenAutoFocusEvent = CustomEvent<Record<string, never>>;
type CloseAutoFocusEvent = CustomEvent<Record<string, never>>;
interface TourRootProps extends DivProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
value?: number;
defaultValue?: number;
onValueChange?: (step: number) => void;
onComplete?: () => void;
onSkip?: () => void;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerDownOutsideEvent) => void;
onInteractOutside?: (event: InteractOutsideEvent) => void;
onOpenAutoFocus?: (event: OpenAutoFocusEvent) => void;
onCloseAutoFocus?: (event: CloseAutoFocusEvent) => void;
dir?: Direction;
alignOffset?: number;
sideOffset?: number;
spotlightPadding?: number;
autoScroll?: boolean;
scrollBehavior?: ScrollBehavior;
scrollOffset?: ScrollOffset;
dismissible?: boolean;
modal?: boolean;
stepFooter?: React.ReactElement;
}
function TourRoot(props: TourRootProps) {
const {
open: openProp,
defaultOpen = false,
onOpenChange,
value: valueProp,
defaultValue = 0,
onValueChange,
onComplete,
onSkip,
autoScroll = true,
scrollBehavior = getDefaultScrollBehavior(),
scrollOffset,
...rootProps
} = props;
const stateRef = useLazyRef<StoreState>(() => ({
open: openProp ?? defaultOpen,
value: valueProp ?? defaultValue,
steps: [],
maskPath: "",
spotlightRect: null,
}));
const listenersRef = useLazyRef<Set<() => void>>(() => new Set());
const stepIdsMapRef = useLazyRef<Map<string, number>>(() => new Map());
const stepIdCounterRef = useLazyRef(() => ({ current: 0 }));
const propsRef = useAsRef({
valueProp,
onOpenChange,
onValueChange,
onComplete,
onSkip,
autoScroll,
scrollBehavior,
scrollOffset,
});
const store: Store = React.useMemo(
() => ({
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => {
return stateRef.current;
},
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
stateRef.current[key] = value;
if (key === "open" && typeof value === "boolean") {
propsRef.current.onOpenChange?.(value);
if (value) {
if (stateRef.current.steps.length > 0) {
if (stateRef.current.value >= stateRef.current.steps.length) {
store.setState("value", 0);
}
}
} else {
if (
stateRef.current.value <
(stateRef.current.steps.length || 0) - 1
) {
propsRef.current.onSkip?.();
}
}
} else if (key === "value" && typeof value === "number") {
const prevStep = stateRef.current.steps[stateRef.current.value];
const nextStep = stateRef.current.steps[value];
prevStep?.onStepLeave?.();
nextStep?.onStepEnter?.();
if (propsRef.current.valueProp !== undefined) {
propsRef.current.onValueChange?.(value);
return;
}
propsRef.current.onValueChange?.(value);
if (value >= stateRef.current.steps.length) {
propsRef.current.onComplete?.();
store.setState("open", false);
return;
}
if (nextStep && propsRef.current.autoScroll) {
const targetElement = getTargetElement(nextStep.target);
if (targetElement) {
onScrollToElement(
targetElement,
propsRef.current.scrollBehavior,
propsRef.current.scrollOffset,
);
}
}
}
store.notify();
},
notify: () => {
listenersRef.current.forEach((l) => {
l();
});
},
addStep: (stepData) => {
const id = `step-${stepIdCounterRef.current.current++}`;
const index = stateRef.current.steps.length;
stepIdsMapRef.current.set(id, index);
stateRef.current.steps = [...stateRef.current.steps, stepData];
store.notify();
return { id, index };
},
removeStep: (id) => {
const index = stepIdsMapRef.current.get(id);
if (index === undefined) return;
stateRef.current.steps = stateRef.current.steps.filter(
(_, i) => i !== index,
);
stepIdsMapRef.current.delete(id);
for (const [stepId, stepIndex] of stepIdsMapRef.current.entries()) {
if (stepIndex > index) {
stepIdsMapRef.current.set(stepId, stepIndex - 1);
}
}
store.notify();
},
}),
[stateRef, listenersRef, stepIdsMapRef, stepIdCounterRef, propsRef],
);
useIsomorphicLayoutEffect(() => {
if (openProp !== undefined) {
store.setState("open", openProp);
}
}, [openProp, store]);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp, store]);
return (
<StoreContext.Provider value={store}>
<TourRootImpl {...rootProps} />
</StoreContext.Provider>
);
}
interface TourRootImplProps
extends Omit<
TourRootProps,
| "open"
| "defaultOpen"
| "onOpenChange"
| "value"
| "defaultValue"
| "onValueChange"
| "onComplete"
| "onSkip"
| "autoScroll"
| "scrollBehavior"
| "scrollOffset"
> {}
function TourRootImpl(props: TourRootImplProps) {
const {
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
onOpenAutoFocus,
onCloseAutoFocus,
dir: dirProp,
alignOffset = DEFAULT_ALIGN_OFFSET,
sideOffset = DEFAULT_SIDE_OFFSET,
spotlightPadding = DEFAULT_SPOTLIGHT_PADDING,
dismissible = true,
modal = true,
stepFooter,
asChild,
...rootImplProps
} = props;
const store = useStoreContext("TourRootImpl");
const dir = useDirection(dirProp);
const [portal, setPortal] = React.useState<HTMLElement | null>(null);
const previouslyFocusedElementRef = React.useRef<HTMLElement | null>(null);
const onEscapeKeyDownRef = useAsRef(onEscapeKeyDown);
const onCloseAutoFocusRef = useAsRef(onCloseAutoFocus);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (store.getState().open && event.key === "Escape") {
if (onEscapeKeyDownRef.current) {
onEscapeKeyDownRef.current(event);
if (event.defaultPrevented) return;
}
store.setState("open", false);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [store, onEscapeKeyDownRef]);
const open = useStore((state) => state.open);
const prevOpenRef = React.useRef<boolean | undefined>(undefined);
useIsomorphicLayoutEffect(() => {
const wasOpen = prevOpenRef.current;
if (open && !wasOpen) {
previouslyFocusedElementRef.current =
document.activeElement as HTMLElement | null;
} else if (!open && wasOpen) {
setTimeout(() => {
const container = portal ?? document.body;
const closeAutoFocusEvent = new CustomEvent(
CLOSE_AUTO_FOCUS,
EVENT_OPTIONS,
);
if (onCloseAutoFocusRef.current) {
container.addEventListener(
CLOSE_AUTO_FOCUS,
onCloseAutoFocusRef.current as EventListener,
{ once: true },
);
}
container.dispatchEvent(closeAutoFocusEvent);
if (!closeAutoFocusEvent.defaultPrevented) {
const elementToFocus = previouslyFocusedElementRef.current;
if (elementToFocus && document.body.contains(elementToFocus)) {
elementToFocus.focus({ preventScroll: true });
}
}
previouslyFocusedElementRef.current = null;
}, 0);
}
prevOpenRef.current = open;
}, [open, portal, onCloseAutoFocusRef]);
const contextValue = React.useMemo<TourContextValue>(
() => ({
dir,
alignOffset,
sideOffset,
spotlightPadding,
dismissible,
modal,
stepFooter,
onPointerDownOutside,
onInteractOutside,
onOpenAutoFocus,
onCloseAutoFocus,
}),
[
dir,
alignOffset,
sideOffset,
spotlightPadding,
dismissible,
modal,
stepFooter,
onPointerDownOutside,
onInteractOutside,
onOpenAutoFocus,
onCloseAutoFocus,
],
);
const portalContextValue = React.useMemo<PortalContextValue>(
() => ({
portal,
onPortalChange: setPortal,
}),
[portal],
);
useScrollLock(open && modal);
const RootPrimitive = asChild ? Slot : "div";
return (
<TourContext.Provider value={contextValue}>
<PortalContext.Provider value={portalContextValue}>
<RootPrimitive data-slot="tour" dir={dir} {...rootImplProps} />
</PortalContext.Provider>
</TourContext.Provider>
);
}
interface TourStepProps extends DivProps {
target: string | React.RefObject<HTMLElement> | HTMLElement;
side?: Side;
sideOffset?: number;
align?: Align;
alignOffset?: number;
collisionBoundary?: Boundary | Boundary[];
collisionPadding?: number | Partial<Record<Side, number>>;
arrowPadding?: number;
sticky?: "partial" | "always";
hideWhenDetached?: boolean;
avoidCollisions?: boolean;
required?: boolean;
forceMount?: boolean;
onStepEnter?: () => void;
onStepLeave?: () => void;
}
function TourStep(props: TourStepProps) {
const {
target,
side = "bottom",
sideOffset,
align = "center",
alignOffset,
collisionBoundary = [],
collisionPadding = 0,
arrowPadding = 0,
sticky = "partial",
hideWhenDetached = false,
avoidCollisions = true,
required = false,
forceMount = false,
onStepEnter,
onStepLeave,
onPointerDownCapture: onPointerDownCaptureProp,
onFocusCapture: onFocusCaptureProp,
onBlurCapture: onBlurCaptureProp,
children,
className,
style,
asChild,
...stepProps
} = props;
const store = useStoreContext(STEP_NAME);
const [arrow, setArrow] = React.useState<HTMLSpanElement | null>(null);
const [footer, setFooter] = React.useState<FooterElement | null>(null);
const stepRef = React.useRef<StepElement | null>(null);
const stepIdRef = React.useRef<string>("");
const stepOrderRef = React.useRef<number>(-1);
const isPointerInsideReactTreeRef = React.useRef(false);
const isFocusInsideReactTreeRef = React.useRef(false);
const open = useStore((state) => state.open);
const value = useStore((state) => state.value);
const steps = useStore((state) => state.steps);
const context = useTourContext(STEP_NAME);
const resolvedSideOffset = sideOffset ?? context.sideOffset;
const resolvedAlignOffset = alignOffset ?? context.alignOffset;
useIsomorphicLayoutEffect(() => {
const { id, index } = store.addStep({
target,
align,
alignOffset: resolvedAlignOffset,
side,
sideOffset: resolvedSideOffset,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
avoidCollisions,
onStepEnter,
onStepLeave,
required,
});
stepIdRef.current = id;
stepOrderRef.current = index;
return () => {
store.removeStep(stepIdRef.current);
};
}, [
target,
side,
resolvedSideOffset,
align,
resolvedAlignOffset,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
avoidCollisions,
required,
onStepEnter,
onStepLeave,
store,
]);
const stepData = steps[value];
const targetElement = stepData ? getTargetElement(stepData.target) : null;
const isCurrentStep = stepOrderRef.current === value;
const middleware = React.useMemo(() => {
if (!stepData) return [];
const mainAxisOffset = stepData.sideOffset ?? resolvedSideOffset;
const crossAxisOffset = stepData.alignOffset ?? resolvedAlignOffset;
const padding =
typeof stepData.collisionPadding === "number"
? stepData.collisionPadding
: {
top: stepData.collisionPadding?.top ?? 0,
right: stepData.collisionPadding?.right ?? 0,
bottom: stepData.collisionPadding?.bottom ?? 0,
left: stepData.collisionPadding?.left ?? 0,
};
const boundary = Array.isArray(stepData.collisionBoundary)
? stepData.collisionBoundary
: stepData.collisionBoundary
? [stepData.collisionBoundary]
: [];
const hasExplicitBoundaries = boundary.length > 0;
const detectOverflowOptions = {
padding,
boundary: boundary.filter((b): b is Element => b !== null),
altBoundary: hasExplicitBoundaries,
};
return [
offset({
mainAxis: mainAxisOffset,
alignmentAxis: crossAxisOffset,
}),
stepData.avoidCollisions &&
shift({
mainAxis: true,
crossAxis: false,
limiter: stepData.sticky === "partial" ? limitShift() : undefined,
...detectOverflowOptions,
}),
stepData.avoidCollisions && flip({ ...detectOverflowOptions }),
arrow && onArrow({ element: arrow, padding: stepData.arrowPadding }),
stepData.hideWhenDetached &&
hide({
strategy: "referenceHidden",
...detectOverflowOptions,
}),
].filter(Boolean) as Middleware[];
}, [stepData, resolvedSideOffset, resolvedAlignOffset, arrow]);
const placement = getPlacement(
stepData?.side ?? side,
stepData?.align ?? align,
);
const {
refs,
floatingStyles,
placement: finalPlacement,
middlewareData,
} = useFloating({
placement,
middleware,
strategy: "fixed",
whileElementsMounted: autoUpdate,
elements: {
reference: targetElement,
},
});
const composedRef = useComposedRefs(refs.setFloating, stepRef);
const [placedSide, placedAlign] =
getSideAndAlignFromPlacement(finalPlacement);
const arrowX = middlewareData.arrow?.x;
const arrowY = middlewareData.arrow?.y;
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
const isHidden = hideWhenDetached && middlewareData.hide?.referenceHidden;
const stepContextValue = React.useMemo<StepContextValue>(
() => ({
arrowX,
arrowY,
placedAlign,
placedSide,
shouldHideArrow: cannotCenterArrow,
onArrowChange: setArrow,
onFooterChange: setFooter,
}),
[arrowX, arrowY, placedSide, placedAlign, cannotCenterArrow],
);
React.useEffect(() => {
if (open && targetElement && isCurrentStep) {
updateMask(store, targetElement, context.spotlightPadding);
let rafId: number | null = null;
function onResize() {
if (targetElement) {
updateMask(store, targetElement, context.spotlightPadding);
}
}
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
if (targetElement) {
updateMask(store, targetElement, context.spotlightPadding);
}
rafId = null;
});
}
window.addEventListener("resize", onResize);
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("resize", onResize);
window.removeEventListener("scroll", onScroll);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}
}, [open, targetElement, isCurrentStep, store, context.spotlightPadding]);
React.useEffect(() => {
if (!open || !isCurrentStep) return;
const stepElement = stepRef.current;
if (!stepElement) return;
const ownerDocument = stepElement.ownerDocument;
function onPointerDown(event: PointerEvent) {
if (event.target && !isPointerInsideReactTreeRef.current) {
const pointerDownOutsideEvent = new CustomEvent(POINTER_DOWN_OUTSIDE, {
...EVENT_OPTIONS,
detail: { originalEvent: event },
});
context.onPointerDownOutside?.(pointerDownOutsideEvent);
const interactOutsideEvent = new CustomEvent(INTERACT_OUTSIDE, {
...EVENT_OPTIONS,
detail: { originalEvent: event },
});
context.onInteractOutside?.(interactOutsideEvent);
if (
!pointerDownOutsideEvent.defaultPrevented &&
!interactOutsideEvent.defaultPrevented &&
context.dismissible
) {
store.setState("open", false);
}
}
isPointerInsideReactTreeRef.current = false;
}
const timerId = window.setTimeout(() => {
ownerDocument.addEventListener("pointerdown", onPointerDown);
}, 0);
return () => {
window.clearTimeout(timerId);
ownerDocument.removeEventListener("pointerdown", onPointerDown);
};
}, [open, isCurrentStep, store, context]);
React.useEffect(() => {
if (!open || !isCurrentStep) return;
const stepElement = stepRef.current;
if (!stepElement) return;
const ownerDocument = stepElement.ownerDocument;
function onFocusIn(event: FocusEvent) {
const target = event.target as HTMLElement;
const isFocusInStep = stepElement?.contains(target);
const isFocusInTarget = targetElement?.contains(target);
if (
event.target &&
!isFocusInsideReactTreeRef.current &&
!isFocusInStep &&
!isFocusInTarget
) {
const interactOutsideEvent = new CustomEvent(INTERACT_OUTSIDE, {
...EVENT_OPTIONS,
detail: { originalEvent: event },
});
context.onInteractOutside?.(interactOutsideEvent);
if (!interactOutsideEvent.defaultPrevented && context.dismissible) {
store.setState("open", false);
}
}
}
ownerDocument.addEventListener("focusin", onFocusIn);
return () => {
ownerDocument.removeEventListener("focusin", onFocusIn);
};
}, [open, isCurrentStep, store, context, targetElement]);
const onPointerDownCapture = React.useCallback(
(event: React.PointerEvent<StepElement>) => {
onPointerDownCaptureProp?.(event);
isPointerInsideReactTreeRef.current = true;
},
[onPointerDownCaptureProp],
);
const onFocusCapture = React.useCallback(
(event: React.FocusEvent<StepElement>) => {
onFocusCaptureProp?.(event);
isFocusInsideReactTreeRef.current = true;
},
[onFocusCaptureProp],
);
const onBlurCapture = React.useCallback(
(event: React.FocusEvent<StepElement>) => {
onBlurCaptureProp?.(event);
isFocusInsideReactTreeRef.current = false;
},
[onBlurCaptureProp],
);
React.useEffect(() => {
if (!open || !isCurrentStep || !targetElement) return;
function onTargetPointerDownCapture() {
isPointerInsideReactTreeRef.current = true;
}
function onTargetFocusCapture() {
isFocusInsideReactTreeRef.current = true;
}
function onTargetBlurCapture() {
isFocusInsideReactTreeRef.current = false;
}
targetElement.addEventListener(
"pointerdown",
onTargetPointerDownCapture,
true,
);
targetElement.addEventListener("focus", onTargetFocusCapture, true);
targetElement.addEventListener("blur", onTargetBlurCapture, true);
return () => {
targetElement.removeEventListener(
"pointerdown",
onTargetPointerDownCapture,
true,
);
targetElement.removeEventListener("focus", onTargetFocusCapture, true);
targetElement.removeEventListener("blur", onTargetBlurCapture, true);
};
}, [open, isCurrentStep, targetElement]);
useFocusGuards();
useFocusTrap(
stepRef,
open && isCurrentStep,
open,
context.onOpenAutoFocus,
context.onCloseAutoFocus,
);
if (!open || !stepData || (!targetElement && !forceMount) || !isCurrentStep) {
return null;
}
const StepPrimitive = asChild ? Slot : "div";
return (
<StepContext.Provider value={stepContextValue}>
<StepPrimitive
ref={composedRef}
data-slot="tour-step"
data-side={placedSide}
data-align={placedAlign}
dir={context.dir}
tabIndex={-1}
{...stepProps}
onPointerDownCapture={onPointerDownCapture}
onFocusCapture={onFocusCapture}
onBlurCapture={onBlurCapture}
className={cn(
"fixed z-50 flex w-80 flex-col gap-4 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none",
className,
)}
style={{
...style,
...floatingStyles,
visibility: isHidden ? "hidden" : undefined,
pointerEvents: isHidden ? "none" : undefined,
}}
>
{children}
{!footer && (
<DefaultFooterContext.Provider value={true}>
{context.stepFooter}
</DefaultFooterContext.Provider>
)}
</StepPrimitive>
</StepContext.Provider>
);
}
interface TourSpotlightProps extends DivProps {
forceMount?: boolean;
}
function TourSpotlight(props: TourSpotlightProps) {
const {
asChild,
className,
style,
forceMount = false,
...backdropProps
} = props;
const open = useStore((state) => state.open);
const maskPath = useStore((state) => state.maskPath);
if (!open && !forceMount) return null;
const SpotlightPrimitive = asChild ? Slot : "div";
return (
<SpotlightPrimitive
data-slot="tour-spotlight"
data-state={getDataState(open)}
{...backdropProps}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
style={{
clipPath: maskPath,
...style,
}}
/>
);
}
interface TourSpotlightRingProps extends DivProps {
forceMount?: boolean;
}
function TourSpotlightRing(props: TourSpotlightRingProps) {
const { asChild, className, style, forceMount = false, ...ringProps } = props;
const open = useStore((state) => state.open);
const spotlightRect = useStore((state) => state.spotlightRect);
if (!open && !forceMount) return null;
if (!spotlightRect) return null;
const RingPrimitive = asChild ? Slot : "div";
return (
<RingPrimitive
data-slot="tour-spotlight-ring"
data-state={getDataState(open)}
{...ringProps}
className={cn(
"pointer-events-none fixed z-50 border-ring ring-[3px] ring-ring/50",
className,
)}
style={{
left: spotlightRect.x,
top: spotlightRect.y,
width: spotlightRect.width,
height: spotlightRect.height,
...style,
}}
/>
);
}
interface TourPortalProps {
children?: React.ReactNode;
container?: HTMLElement | null;
}
function TourPortal(props: TourPortalProps) {
const { children, container } = props;
const portalContext = usePortalContext(PORTAL_NAME);
const [mounted, setMounted] = React.useState(false);
useIsomorphicLayoutEffect(() => {
setMounted(true);
const node = container ?? document.body;
portalContext?.onPortalChange(node);
return () => {
portalContext?.onPortalChange(null);
};
}, [container, portalContext]);
if (!mounted) return null;
const portalContainer = container ?? portalContext?.portal ?? document.body;
return ReactDOM.createPortal(children, portalContainer);
}
interface TourArrowProps extends React.ComponentProps<"svg"> {
width?: number;
height?: number;
asChild?: boolean;
}
function TourArrow(props: TourArrowProps) {
const {
width = 10,
height = 5,
className,
children,
asChild,
...arrowProps
} = props;
const stepContext = useStepContext(ARROW_NAME);
const baseSide = OPPOSITE_SIDE[stepContext.placedSide];
return (
<span
ref={stepContext.onArrowChange}
data-slot="tour-arrow"
style={{
position: "absolute",
left:
stepContext.arrowX != null ? `${stepContext.arrowX}px` : undefined,
top: stepContext.arrowY != null ? `${stepContext.arrowY}px` : undefined,
[baseSide]: 0,
transformOrigin: {
top: "",
right: "0 0",
bottom: "center 0",
left: "100% 0",
}[stepContext.placedSide],
transform: {
top: "translateY(100%)",
right: "translateY(50%) rotate(90deg) translateX(-50%)",
bottom: "rotate(180deg)",
left: "translateY(50%) rotate(-90deg) translateX(50%)",
}[stepContext.placedSide],
visibility: stepContext.shouldHideArrow ? "hidden" : undefined,
}}
>
<svg
viewBox="0 0 30 10"
preserveAspectRatio="none"
width={width}
height={height}
{...arrowProps}
className={cn("block fill-popover stroke-border", className)}
>
{asChild ? children : <polygon points="0,0 30,0 15,10" />}
</svg>
</span>
);
}
function TourHeader(props: DivProps) {
const { asChild, className, ...headerProps } = props;
const context = useTourContext(HEADER_NAME);
const HeaderPrimitive = asChild ? Slot : "div";
return (
<HeaderPrimitive
data-slot="tour-header"
dir={context.dir}
{...headerProps}
className={cn(
"flex flex-col gap-1.5 text-center sm:text-left",
className,
)}
/>
);
}
function TourTitle(props: DivProps) {
const { asChild, className, ...titleProps } = props;
const context = useTourContext(TITLE_NAME);
const TitlePrimitive = asChild ? Slot : "div";
return (
<TitlePrimitive
data-slot="tour-title"
dir={context.dir}
{...titleProps}
className={cn(
"font-semibold text-lg leading-none tracking-tight",
className,
)}
/>
);
}
function TourDescription(props: DivProps) {
const { asChild, className, ...descriptionProps } = props;
const context = useTourContext(DESCRIPTION_NAME);
const DescriptionPrimitive = asChild ? Slot : "div";
return (
<DescriptionPrimitive
data-slot="tour-description"
dir={context.dir}
{...descriptionProps}
className={cn("text-muted-foreground text-sm", className)}
/>
);
}
interface TourCloseProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function TourClose(props: TourCloseProps) {
const {
asChild,
className,
onClick: onClickProp,
...closeButtonProps
} = props;
const store = useStoreContext(CLOSE_NAME);
const onClick = React.useCallback(
(event: React.MouseEvent<CloseElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
store.setState("open", false);
},
[store, onClickProp],
);
const ClosePrimitive = asChild ? Slot : "button";
return (
<ClosePrimitive
type="button"
aria-label="Close tour"
className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
onClick={onClick}
{...closeButtonProps}
>
<X className="size-4" />
</ClosePrimitive>
);
}
function TourPrev(props: React.ComponentProps<typeof Button>) {
const { children, onClick: onClickProp, ...prevButtonProps } = props;
const store = useStoreContext(PREV_NAME);
const value = useStore((state) => state.value);
const onClick = React.useCallback(
(event: React.MouseEvent<PrevElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
if (value > 0) {
store.setState("value", value - 1);
}
},
[value, store, onClickProp],
);
return (
<Button
type="button"
aria-label="Previous step"
data-slot="tour-prev"
variant="outline"
{...prevButtonProps}
onClick={onClick}
disabled={value === 0}
>
{children ?? (
<>
<ChevronLeft />
Previous
</>
)}
</Button>
);
}
function TourNext(props: React.ComponentProps<typeof Button>) {
const { children, onClick: onClickProp, ...nextButtonProps } = props;
const store = useStoreContext(NEXT_NAME);
const value = useStore((state) => state.value);
const steps = useStore((state) => state.steps);
const isLastStep = value === steps.length - 1;
const onClick = React.useCallback(
(event: React.MouseEvent<NextElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
store.setState("value", value + 1);
},
[value, store, onClickProp],
);
return (
<Button
type="button"
aria-label="Next step"
data-slot="tour-next"
{...nextButtonProps}
onClick={onClick}
>
{children ?? (
<>
{isLastStep ? "Finish" : "Next"}
{!isLastStep && <ChevronRight />}
</>
)}
</Button>
);
}
function TourSkip(props: React.ComponentProps<typeof Button>) {
const { children, onClick: onClickProp, ...skipButtonProps } = props;
const store = useStoreContext(SKIP_NAME);
const onClick = React.useCallback(
(event: React.MouseEvent<SkipElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
store.setState("open", false);
},
[store, onClickProp],
);
return (
<Button
type="button"
aria-label="Skip tour"
data-slot="tour-skip"
variant="outline"
{...skipButtonProps}
onClick={onClick}
>
{children ?? "Skip"}
</Button>
);
}
interface TourStepCounterProps extends DivProps {
format?: (current: number, total: number) => string;
}
function TourStepCounter(props: TourStepCounterProps) {
const {
format = (current, total) => `${current} / ${total}`,
asChild,
className,
children,
...stepCounterProps
} = props;
const value = useStore((state) => state.value);
const steps = useStore((state) => state.steps);
const StepCounterPrimitive = asChild ? Slot : "div";
return (
<StepCounterPrimitive
data-slot="tour-step-counter"
{...stepCounterProps}
className={cn("text-muted-foreground text-sm", className)}
>
{children ?? format(value + 1, steps.length)}
</StepCounterPrimitive>
);
}
function TourFooter(props: DivProps) {
const { asChild, className, ref, ...footerProps } = props;
const stepContext = useStepContext(FOOTER_NAME);
const hasDefaultFooter = React.useContext(DefaultFooterContext);
const context = useTourContext(FOOTER_NAME);
const composedRef = useComposedRefs(
ref,
hasDefaultFooter ? undefined : stepContext.onFooterChange,
);
const FooterPrimitive = asChild ? Slot : "div";
return (
<FooterPrimitive
data-slot="tour-footer"
dir={context.dir}
{...footerProps}
ref={composedRef}
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
/>
);
}
export {
TourRoot as Root,
TourPortal as Portal,
TourSpotlight as Spotlight,
TourSpotlightRing as SpotlightRing,
TourStep as Step,
TourArrow as Arrow,
TourHeader as Header,
TourTitle as Title,
TourDescription as Description,
TourClose as Close,
TourPrev as Prev,
TourNext as Next,
TourSkip as Skip,
TourStepCounter as StepCounter,
TourFooter as Footer,
//
TourRoot as Tour,
TourPortal,
TourSpotlight,
TourSpotlightRing,
TourStep,
TourArrow,
TourHeader,
TourTitle,
TourDescription,
TourClose,
TourPrev,
TourNext,
TourSkip,
TourStepCounter,
TourFooter,
//
type TourRootProps as TourProps,
};Layout
Import the parts, and compose them together.
import * as Tour from "@/components/ui/tour";
<Tour.Root>
<Tour.Portal>
<Tour.Spotlight />
<Tour.SpotlightRing />
<Tour.Step>
<Tour.Arrow />
<Tour.Close />
<Tour.Header>
<Tour.Title />
<Tour.Description />
</Tour.Header>
<Tour.Footer>
<Tour.StepCounter />
<Tour.Prev />
<Tour.Next />
<Tour.Skip />
</Tour.Footer>
</Tour.Step>
</Tour.Portal>
</Tour.Root>Examples
Controlled
A tour with controlled state management, allowing external control of the current step.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Tour,
TourArrow,
TourClose,
TourDescription,
TourFooter,
TourHeader,
TourNext,
TourPrev,
TourSkip,
TourSpotlight,
TourSpotlightRing,
TourStep,
TourStepCounter,
TourTitle,
} from "@/components/ui/tour";
export function TourControlledDemo() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(0);
const onStepChange = React.useCallback((step: number) => {
setValue(step);
console.log(`Step changed to: ${step + 1}`);
}, []);
const onComplete = React.useCallback(() => {
console.log("Tour completed!");
setOpen(false);
setValue(0);
}, []);
const onSkip = React.useCallback(() => {
console.log("Tour skipped!");
setOpen(false);
setValue(0);
}, []);
const onTourStart = React.useCallback(() => {
setValue(0);
setOpen(true);
}, []);
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-8 p-8">
<div className="flex flex-col items-center gap-4">
<h1 id="controlled-title" className="font-bold text-2xl">
Controlled Tour Example
</h1>
<p className="text-center text-muted-foreground">
This tour demonstrates controlled state management with external step
tracking.
</p>
<div className="flex gap-2">
<Button id="controlled-start-btn" onClick={onTourStart}>
Start Controlled Tour
</Button>
<Button
variant="outline"
onClick={() => setValue(Math.max(0, value - 1))}
disabled={!open || value === 0}
>
External Prev
</Button>
<Button
variant="outline"
onClick={() => setValue(Math.min(2, value + 1))}
disabled={!open || value === 2}
>
External Next
</Button>
</div>
{open && (
<p className="text-muted-foreground text-sm">
Current step: {value + 1} / 3
</p>
)}
</div>
<div className="grid grid-cols-2 gap-6">
<div
id="controlled-step-1"
className="rounded-lg border p-6 text-center"
>
<h3 className="font-semibold">Step 1</h3>
<p className="text-muted-foreground text-sm">
First step in our controlled tour
</p>
</div>
<div
id="controlled-step-2"
className="rounded-lg border p-6 text-center"
>
<h3 className="font-semibold">Step 2</h3>
<p className="text-muted-foreground text-sm">
Second step with external controls
</p>
</div>
</div>
<Tour
open={open}
onOpenChange={setOpen}
value={value}
onValueChange={onStepChange}
onComplete={onComplete}
onSkip={onSkip}
>
<TourSpotlight />
<TourSpotlightRing />
<TourStep target="#controlled-title" side="bottom" align="center">
<TourArrow />
<TourHeader>
<TourTitle>Controlled Tour</TourTitle>
<TourDescription>
This tour's state is controlled externally. Notice how the step
counter updates.
</TourDescription>
</TourHeader>
<TourFooter>
<div className="flex w-full items-center justify-between">
<TourStepCounter />
<div className="flex gap-2">
<TourSkip />
<TourNext />
</div>
</div>
</TourFooter>
<TourClose />
</TourStep>
<TourStep target="#controlled-step-1" side="top" align="center">
<TourArrow />
<TourHeader>
<TourTitle>External Controls</TourTitle>
<TourDescription>
You can control this tour using the external buttons above, or use
the built-in navigation.
</TourDescription>
</TourHeader>
<TourFooter>
<div className="flex w-full items-center justify-between">
<TourStepCounter />
<div className="flex gap-2">
<TourPrev />
<TourNext />
</div>
</div>
</TourFooter>
<TourClose />
</TourStep>
<TourStep target="#controlled-step-2" side="top" align="center">
<TourArrow />
<TourHeader>
<TourTitle>Final Step</TourTitle>
<TourDescription>
This is the last step. The tour state is fully controlled by the
parent component.
</TourDescription>
</TourHeader>
<TourFooter>
<div className="flex w-full items-center justify-between">
<TourStepCounter />
<div className="flex gap-2">
<TourPrev />
<TourNext />
</div>
</div>
</TourFooter>
<TourClose />
</TourStep>
</Tour>
</div>
);
}Custom Spotlight Styling
You can customize the appearance of the spotlighted element using the SpotlightRing component:
<Tour.Root open={open} onOpenChange={setOpen}>
<Tour.Portal>
<Tour.Spotlight />
{/* Border style */}
<Tour.SpotlightRing className="rounded-lg border-2 border-primary" />
{/* Ring with offset */}
<Tour.SpotlightRing className="rounded-xl ring-2 ring-blue-500 ring-offset-2" />
{/* Glowing effect */}
<Tour.SpotlightRing className="rounded-lg shadow-lg shadow-primary/50" />
{/* Animated pulse */}
<Tour.SpotlightRing className="rounded-lg border-2 border-primary animate-pulse" />
<Tour.Step target="#element">{/* ... */}</Tour.Step>
</Tour.Portal>
</Tour.Root>Global Offset Control
Set default spacing for all steps and override per step:
<Tour.Root
open={open}
onOpenChange={setOpen}
sideOffset={16} // Global default: 16px gap
alignOffset={0} // Global alignment offset
>
<Tour.Portal>
<Tour.Spotlight />
<Tour.SpotlightRing />
{/* Uses global sideOffset={16} */}
<Tour.Step target="#step-1" side="bottom">
<Tour.Header>
<Tour.Title>Step 1</Tour.Title>
</Tour.Header>
</Tour.Step>
{/* Overrides with custom sideOffset={32} */}
<Tour.Step target="#step-2" side="top" sideOffset={32}>
<Tour.Header>
<Tour.Title>Step 2 - Larger Gap</Tour.Title>
</Tour.Header>
</Tour.Step>
</Tour.Portal>
</Tour.Root>API Reference
Root
The main container component for the tour that manages state and provides context.
Prop
Type
Spotlight
The spotlight backdrop that dims the page and highlights the target element with a cutout effect.
Prop
Type
| Data Attribute | Value |
|---|
SpotlightRing
A visual ring/border element that wraps around the spotlighted target element. Use this to add custom styling like borders, shadows, or animations to the highlighted area.
Prop
Type
| Data Attribute | Value |
|---|
Step
A single step in the tour that targets a specific element on the page.
Prop
Type
| Data Attribute | Value |
|---|
Close
Button to close the entire tour.
Prop
Type
Header
Container for the tour step's title and description.
Prop
Type
Title
The title of the current tour step.
Prop
Type
Description
The description text for the current tour step.
Prop
Type
Footer
Container for tour navigation controls and step counter.
Prop
Type
StepCounter
Displays the current step number and total steps.
Prop
Type
Prev
Button to navigate to the previous step.
Prop
Type
Next
Button to navigate to the next step or complete the tour.
Prop
Type
Skip
Button to skip the entire tour.
Prop
Type
Arrow
An optional arrow element that points to the target element.
Prop
Type
Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Escape | Triggers the onEscapeKeyDown callback. Default behavior closes the tour unless prevented. |
| Tab | Moves focus to the next focusable element within the tour content. |
| ShiftTab | Moves focus to the previous focusable element within the tour content. |
| EnterSpace | Activates the focused button (next, previous, skip, or close). |
Credits
- Radix UI Dismissable Layer - For the pointer down outside and interact outside event handling patterns.
- Radix UI Focus Guard - For the focus guard implementation.
- Radix UI Dialog - For the focus trap and auto-focus management patterns.