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">
            Welcome to Your Dashboard
          </h1>
          <p className="text-center text-muted-foreground">
            Take a quick tour to explore key features
          </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">Analytics</h3>
          <p className="text-muted-foreground text-sm">
            Track your performance metrics
          </p>
        </div>
        <div id="feature-2" className="rounded-lg border p-4 text-center">
          <h3 className="font-semibold">Projects</h3>
          <p className="text-muted-foreground text-sm">
            Manage your active projects
          </p>
        </div>
        <div id="feature-3" className="rounded-lg border p-4 text-center">
          <h3 className="font-semibold">Team</h3>
          <p className="text-muted-foreground text-sm">
            Collaborate with teammates
          </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>
                Let's walk through the main features of your dashboard in just a
                few steps.
              </TourDescription>
            </TourHeader>
            <TourClose />
          </TourStep>
          <TourStep target="#feature-1" side="top" align="center">
            <TourArrow />
            <TourHeader>
              <TourTitle>Analytics Dashboard</TourTitle>
              <TourDescription>
                View real-time insights, track KPIs, and monitor your team's
                progress with interactive charts.
              </TourDescription>
            </TourHeader>
            <TourClose />
          </TourStep>
          <TourStep target="#feature-2" side="top" align="center">
            <TourArrow />
            <TourHeader>
              <TourTitle>Project Management</TourTitle>
              <TourDescription>
                Create, organize, and track projects with powerful tools for
                task management and deadlines.
              </TourDescription>
            </TourHeader>
            <TourClose />
          </TourStep>
          <TourStep target="#feature-3" side="top" align="center" required>
            <TourArrow />
            <TourHeader>
              <TourTitle>Team Collaboration</TourTitle>
              <TourDescription>
                Invite members, assign roles, and collaborate seamlessly. This
                step is required to continue.
              </TourDescription>
            </TourHeader>
            <TourClose />
          </TourStep>
        </TourPortal>
      </Tour>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add @diceui/tour

Manual

Install the following dependencies:

npm install @floating-ui/react-dom @radix-ui/react-slot lucide-react

Copy and paste the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following hooks into your hooks directory.

import * as React from "react";
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
export { useIsomorphicLayoutEffect };
import * as React from "react";
 
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>;
}
 
export { useLazyRef };
import * as React from "react";
 
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
 
function useAsRef<T>(props: T) {
  const ref = React.useRef<T>(props);
 
  useIsomorphicLayoutEffect(() => {
    ref.current = props;
  });
 
  return ref;
}
 
export { useAsRef };

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 { useDirection } from "@radix-ui/react-direction";
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";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
 
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",
};
 
/**
 * @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,
  ]);
}
 
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,
  ogStore?: Store | null,
): T {
  const contextStore = React.useContext(StoreContext);
 
  const store = ogStore ?? contextStore;
 
  if (!store) {
    throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
  }
 
  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 TourProps 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 Tour(props: TourProps) {
  const {
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    value: valueProp,
    defaultValue = 0,
    onValueChange,
    onComplete,
    onSkip,
    autoScroll = true,
    scrollBehavior = getDefaultScrollBehavior(),
    scrollOffset,
    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,
    ...rootProps
  } = props;
 
  const dir = useDirection(dirProp);
 
  const [portal, setPortal] = React.useState<HTMLElement | null>(null);
  const prevOpenRef = React.useRef<boolean | undefined>(undefined);
  const previouslyFocusedElementRef = React.useRef<HTMLElement | null>(null);
 
  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,
    onEscapeKeyDown,
    onCloseAutoFocus,
    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 (value >= stateRef.current.steps.length) {
            propsRef.current.onComplete?.();
 
            if (propsRef.current.valueProp !== undefined) {
              propsRef.current.onValueChange?.(value);
            }
 
            store.setState("open", false);
            return;
          }
 
          if (propsRef.current.valueProp !== undefined) {
            propsRef.current.onValueChange?.(value);
            return;
          }
 
          propsRef.current.onValueChange?.(value);
 
          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],
  );
 
  const open = useStore((state) => state.open, store);
 
  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (open && event.key === "Escape") {
        if (propsRef.current.onEscapeKeyDown) {
          propsRef.current.onEscapeKeyDown(event);
          if (event.defaultPrevented) return;
        }
        store.setState("open", false);
      }
    }
 
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [store, open, propsRef]);
 
  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 (propsRef.current.onCloseAutoFocus) {
          container.addEventListener(
            CLOSE_AUTO_FOCUS,
            propsRef.current.onCloseAutoFocus 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, propsRef]);
 
  useIsomorphicLayoutEffect(() => {
    if (openProp !== undefined) {
      store.setState("open", openProp);
    }
  }, [openProp, store]);
 
  useIsomorphicLayoutEffect(() => {
    if (valueProp !== undefined) {
      store.setState("value", valueProp);
    }
  }, [valueProp, store]);
 
  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 (
    <StoreContext.Provider value={store}>
      <TourContext.Provider value={contextValue}>
        <PortalContext.Provider value={portalContextValue}>
          <RootPrimitive data-slot="tour" dir={dir} {...rootProps} />
        </PortalContext.Provider>
      </TourContext.Provider>
    </StoreContext.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 {
  Tour,
  TourPortal,
  TourSpotlight,
  TourSpotlightRing,
  TourStep,
  TourArrow,
  TourHeader,
  TourTitle,
  TourDescription,
  TourClose,
  TourPrev,
  TourNext,
  TourSkip,
  TourStepCounter,
  TourFooter,
  //
  type TourProps,
};

Update the import paths to match your project setup.

Layout

Import the parts, and compose them together.

import {
  Tour,
  TourPortal,
  TourSpotlight,
  TourSpotlightRing,
  TourStep,
  TourArrow,
  TourClose,
  TourHeader,
  TourTitle,
  TourDescription,
  TourFooter,
  TourStepCounter,
  TourPrev,
  TourNext,
  TourSkip,
} from "@/components/ui/tour";

return (
  <Tour>
    <TourPortal>
      <TourSpotlight />
      <TourSpotlightRing />
      <TourStep>
        <TourArrow />
        <TourClose />
        <TourHeader>
          <TourTitle />
          <TourDescription />
        </TourHeader>
        <TourFooter>
          <TourStepCounter />
          <TourPrev />
          <TourNext />
          <TourSkip />
        </TourFooter>
      </TourStep>
    </TourPortal>
  </Tour>
)

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);
  }, []);
 
  const onComplete = React.useCallback(() => {
    setOpen(false);
    setValue(0);
  }, []);
 
  const onSkip = React.useCallback(() => {
    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
        </h1>
        <div className="flex items-center gap-2">
          <Button id="controlled-start-btn" onClick={onTourStart}>
            Start
          </Button>
          <Button
            variant="outline"
            onClick={() => setValue(Math.max(0, value - 1))}
            disabled={!open || value === 0}
          >
            Prev
          </Button>
          <Button
            variant="outline"
            onClick={() => setValue(Math.min(3, value + 1))}
            disabled={!open || value === 3}
          >
            Next
          </Button>
        </div>
      </div>
      <div className="flex w-full flex-col gap-6">
        <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>
        {open && value >= 2 && (
          <div
            id="controlled-step-3"
            className="fade-in slide-in-from-bottom-4 animate-in rounded-lg border border-primary/50 bg-primary/5 p-6 text-center duration-300"
          >
            <h3 className="font-semibold">Step 3</h3>
            <p className="text-muted-foreground text-sm">
              Dynamic step that appears after step 2
            </p>
          </div>
        )}
      </div>
      <Tour
        open={open}
        onOpenChange={setOpen}
        value={value}
        onValueChange={onStepChange}
        onComplete={onComplete}
        onSkip={onSkip}
        stepFooter={
          <TourFooter>
            <div className="flex w-full items-center justify-between">
              <TourStepCounter />
              <div className="flex gap-2">
                <TourPrev />
                <TourNext />
              </div>
            </div>
          </TourFooter>
        }
      >
        <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>
          <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>
          <TourClose />
        </TourStep>
        <TourStep target="#controlled-step-2" side="top" align="center">
          <TourArrow />
          <TourHeader>
            <TourTitle>Second Feature</TourTitle>
            <TourDescription>
              The tour state is fully controlled by the parent component. Watch
              what happens next!
            </TourDescription>
          </TourHeader>
          <TourClose />
        </TourStep>
        <TourStep target="#controlled-step-3" side="top" align="center">
          <TourArrow />
          <TourHeader>
            <TourTitle>Dynamic Layout</TourTitle>
            <TourDescription>
              This element appeared when you reached this step, demonstrating
              how the tour handles dynamic content and layout shifts.
            </TourDescription>
          </TourHeader>
          <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

Tour

The main container component for the tour that manages state and provides context.

Prop

Type

TourSpotlight

The spotlight backdrop that dims the page and highlights the target element with a cutout effect.

Prop

Type

Data AttributeValue

TourSpotlightRing

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

TourStep

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

Prop

Type

Data AttributeValue

TourClose

Button to close the entire tour.

Prop

Type

TourHeader

Container for the tour step's title and description.

Prop

Type

TourTitle

The title of the current tour step.

Prop

Type

TourDescription

The description text for the current tour step.

Prop

Type

TourFooter

Container for tour navigation controls and step counter.

Prop

Type

TourStepCounter

Displays the current step number and total steps.

Prop

Type

TourPrev

Button to navigate to the previous step.

Prop

Type

TourNext

Button to navigate to the next step or complete the tour.

Prop

Type

TourSkip

Button to skip the entire tour.

Prop

Type

TourArrow

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

On this page