Dice UI
Components

Tour

A guided tour component that highlights elements and provides step-by-step instructions to help users learn about your application.

API
"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-react

Copy 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 AttributeValue

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 AttributeValue

Step

A single step in the tour that targets a specific element on the page.

Prop

Type

Data AttributeValue

Close

Button to close the entire tour.

Prop

Type

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

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

KeyDescription
EscapeTriggers the onEscapeKeyDown callback. Default behavior closes the tour unless prevented.
TabMoves focus to the next focusable element within the tour content.
ShiftTabMoves focus to the previous focusable element within the tour content.
EnterSpaceActivates the focused button (next, previous, skip, or close).

Credits