Bloom Logo

Tour Guide

An onboarding assistant spotlighting structural DOM elements step-by-step with focus containers, dimmed background overlay, and directional tooltip cards.

importimport { Tour } from "@/components/ui/tour/tour";
$npx @bloomui-react/cli add tour
tour.tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button/button";
import { Confetti } from "@/components/ui/confetti/confetti";
import { designRadius } from "@/lib/design-system";
import { cn } from "@/lib/utils";

export interface TourStep {
  target: string;
  title: string;
  content: string;
  placement?: "top" | "bottom" | "left" | "right";
}

export interface TourProps {
  steps: TourStep[];
  run: boolean;
  onClose?: () => void;
  radius?: keyof typeof designRadius;
  showConfetti?: boolean;
}

export const Tour: React.FC<TourProps> = ({
  steps,
  run,
  onClose,
  radius = "md",
  showConfetti = false,
}) => {
  const [currentStepIndex, setCurrentStepIndex] = React.useState(0);
  const [coords, setCoords] = React.useState<DOMRect | null>(null);
  const [isMobile, setIsMobile] = React.useState(false);
  const [fireConfetti, setFireConfetti] = React.useState(0);

  const activeStep = steps[currentStepIndex];

  const updateCoords = React.useCallback(() => {
    if (!run || !activeStep) return;
    const element = document.querySelector(activeStep.target);
    if (element) {
      setCoords(element.getBoundingClientRect());
    } else {
      setCoords(null);
    }
  }, [run, activeStep]);

  React.useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 640);
    };
    checkMobile();
    window.addEventListener("resize", checkMobile);
    return () => window.removeEventListener("resize", checkMobile);
  }, []);

  React.useEffect(() => {
    if (!run || !activeStep) return;
    const element = document.querySelector(activeStep.target);
    if (element) {
      element.scrollIntoView({ behavior: "smooth", block: "center" });
      const timer = setTimeout(() => {
        setCoords(element.getBoundingClientRect());
      }, 100);
      return () => clearTimeout(timer);
    }
  }, [run, currentStepIndex, activeStep]);

  React.useEffect(() => {
    let frameId: number;

    const handleScrollOrResize = () => {
      cancelAnimationFrame(frameId);
      frameId = requestAnimationFrame(() => {
        updateCoords();
      });
    };

    updateCoords();
    window.addEventListener("resize", handleScrollOrResize);
    window.addEventListener("scroll", handleScrollOrResize);
    return () => {
      cancelAnimationFrame(frameId);
      window.removeEventListener("resize", handleScrollOrResize);
      window.removeEventListener("scroll", handleScrollOrResize);
    };
  }, [updateCoords]);

  if (!run || !activeStep) return null;

  const handleNext = () => {
    if (currentStepIndex < steps.length - 1) {
      setCurrentStepIndex((prev) => prev + 1);
    } else {
      if (showConfetti) {
        setFireConfetti((prev) => prev + 1);
        setTimeout(() => {
          onClose?.();
          setCurrentStepIndex(0);
          setFireConfetti(0);
        }, 600);
      } else {
        onClose?.();
        setCurrentStepIndex(0);
      }
    }
  };

  const handleBack = () => {
    if (currentStepIndex > 0) {
      setCurrentStepIndex((prev) => prev - 1);
    }
  };

  const handleSkip = () => {
    onClose?.();
    setCurrentStepIndex(0);
  };

  const getPopoverStyle = (): React.CSSProperties => {
    if (isMobile) {
      return {
        position: "fixed",
        bottom: "16px",
        left: "50%",
        transform: "translateX(-50%)",
        width: "calc(100% - 32px)",
        maxWidth: "360px",
      };
    }

    if (!coords) {
      return {
        position: "fixed",
        top: "50%",
        left: "50%",
        transform: "translate(-50%, -50%)",
      };
    }

    const margin = 12;
    const placement = activeStep.placement || "bottom";

    switch (placement) {
      case "top":
        return {
          position: "fixed",
          top: `${coords.top - margin}px`,
          left: `${coords.left + coords.width / 2}px`,
          transform: "translate(-50%, -100%)",
        };
      case "left":
        return {
          position: "fixed",
          top: `${coords.top + coords.height / 2}px`,
          left: `${coords.left - margin}px`,
          transform: "translate(-100%, -50%)",
        };
      case "right":
        return {
          position: "fixed",
          top: `${coords.top + coords.height / 2}px`,
          left: `${coords.right + margin}px`,
          transform: "translate(0, -50%)",
        };
      default:
        return {
          position: "fixed",
          top: `${coords.bottom + margin}px`,
          left: `${coords.left + coords.width / 2}px`,
          transform: "translate(-50%, 0)",
        };
    }
  };

  return (
    <div className="fixed inset-0 z-50 pointer-events-none select-none">
      {fireConfetti > 0 && <Confetti fire={fireConfetti} variant="cannon" />}
      <div className="absolute inset-0 bg-transparent pointer-events-auto" />

      {coords && (
        <div
          style={{
            position: "fixed",
            top: `${coords.top}px`,
            left: `${coords.left}px`,
            width: `${coords.width}px`,
            height: `${coords.height}px`,
          }}
          className={cn(
            "ring-[9999px] ring-black/60 dark:ring-black/80 shadow-[0_0_15px_rgba(255,255,255,0.4)] pointer-events-none transition-all duration-300",
            designRadius[radius],
          )}
        />
      )}

      <div
        style={getPopoverStyle()}
        className={cn(
          "w-80 p-4 pointer-events-auto bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xl transition-all duration-300 z-50",
          designRadius[radius],
        )}
      >
        <div className="flex items-center justify-between mb-2">
          <h3 className="font-semibold text-sm text-zinc-900 dark:text-zinc-100">
            {activeStep.title}
          </h3>
          <span className="text-[10px] text-zinc-400 dark:text-zinc-500 font-mono">
            {currentStepIndex + 1} / {steps.length}
          </span>
        </div>
        <p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4 leading-relaxed">
          {activeStep.content}
        </p>

        <div className="flex items-center justify-between">
          <Button
            size="sm"
            variant="light"
            onClick={handleSkip}
            className="text-xs text-zinc-400 hover:text-zinc-500 dark:hover:text-zinc-300"
          >
            Skip
          </Button>

          <div className="flex items-center gap-1.5">
            {currentStepIndex > 0 && (
              <Button
                size="sm"
                variant="bordered"
                onClick={handleBack}
                className="h-7 text-xs px-2.5"
              >
                Back
              </Button>
            )}
            <Button
              size="sm"
              variant="default"
              color="primary"
              onClick={handleNext}
              className="h-7 text-xs px-3"
            >
              {currentStepIndex === steps.length - 1 ? "Finish" : "Next"}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
};

Tour.displayName = "Tour";

Default

A standard multi-step guide highlighting targets without triggering additional effects upon completion.

steps: TourStep[]run: booleanonClose: () => voidshowConfetti: boolean

Target Area 1

First step targets this panel.

Target Area 2

Second step targets this panel.

With Confetti

Celebrate onboarding completion. Setting showConfetti={true} fires particle effects when clicking Finish.

showConfetti: boolean

Celebration Zone

Finish the tour on this element to trigger confetti.

API Reference

Props — Tour

Configuration options for the Tour component.

PropTypeDefaultDescription
stepsTourStep[][]List of step objects specifying targets, text descriptions, and directions
runbooleanfalseA active switch state that displays/mounts the tour layout
onClose() => voidundefinedFires when the guide is skipped or completed
radiuskeyof typeof designRadius'md'Corner radius of spotlight box and info card
showConfettibooleanfalseTriggers celebratory confetti particles when user clicks 'Finish' on the final step