Kiki

Product Tour

Component 216

About

A spotlight onboarding walkthrough. Dims the page and glides an animated spotlight across real elements, floating an auto-positioning tooltip beside each. Keyboard driven, focus-trapped, reduced-motion aware, fully responsive, light and dark. Ships as <Tour steps> plus a useTour() hook - React and framer-motion only, zero config.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Product Tour to your project and fits it to your theme.

Code

How Product Tour is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { Tour, useTour, type TourStep } from "@/components/ui/product-tour";

const iconProps = {
  viewBox: "0 0 24 24",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.6,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
};

const STEPS: TourStep[] = [
  {
    title: "Welcome to ReturnSplit",
    content:
      "Take the 30-second tour and we'll show you around. You can leave any time with Esc.",
    placement: "center",
  },
  {
    target: "#tour-new",
    title: "Create anything",
    content: "Start a report, invite a teammate, or open a workflow - all from here.",
    placement: "bottom",
  },
  {
    target: "#tour-search",
    title: "Jump to anything",
    content: "Search across projects, people, and docs. Press / from anywhere to focus it.",
    placement: "bottom",
  },
  {
    target: "#tour-revenue",
    title: "Your key metric",
    content: "Revenue updates live. Click any card to break it down by source and region.",
    placement: "right",
  },
  {
    target: "#tour-nav-reports",
    title: "Deeper reports live here",
    content: "Saved views, exports, and scheduled email digests are one tap away.",
    placement: "right",
  },
  {
    target: "#tour-avatar",
    title: "You're all set",
    content: "Manage your workspace and billing from your profile. That's the whole tour!",
    placement: "bottom",
  },
];

export default function ProductTourDemo() {
  const [dark, setDark] = React.useState(false);
  const tour = useTour();

  React.useEffect(() => {
    const t = window.setTimeout(() => tour.start(), 650);
    return () => window.clearTimeout(t);
  }, [tour.start]);

  return (
    <div className={dark ? "dark" : ""}>
      <div className="min-h-screen overflow-x-hidden bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
        <div className="mx-auto flex max-w-6xl gap-0 p-3 sm:p-6 md:h-[600px]">
          <aside className="hidden w-56 shrink-0 flex-col rounded-2xl border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900 md:flex">
            <div className="flex items-center gap-2 px-2 py-1.5">
              <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900">
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M4 12h5" />
                  <path d="M9 12l7-4.5" />
                  <path d="M9 12l7 4.5" />
                  <circle cx="16.5" cy="7" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="16.5" cy="17" r="1.5" fill="currentColor" stroke="none" />
                </svg>
              </div>
              <span className="text-[13px] font-semibold">ReturnSplit</span>
            </div>

            <nav className="mt-4 flex flex-col gap-0.5">
              <NavItem active label="Overview" path="M3 12h7V3H3zM14 21h7v-9h-7zM14 8h7V3h-7zM3 21h7v-5H3z" />
              <NavItem id="tour-nav-reports" label="Reports" path="M4 4h16v4H4zM4 12h10v8H4zM17 12h3v8h-3z" />
              <NavItem label="Customers" path="M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8" />
              <NavItem label="Settings" path="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 7a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H7a1.6 1.6 0 0 0 1-1.5V1a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V7a1.6 1.6 0 0 0 1.5 1H23a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z" />
            </nav>

            <button
              onClick={() => tour.start()}
              className="mt-auto rounded-xl border border-zinc-200 px-3 py-2 text-[12.5px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800/60"
            >
              Replay tour
            </button>
          </aside>

          <main className="min-w-0 flex-1 md:pl-6">
            <header className="flex items-center gap-2 sm:gap-3">
              <div
                id="tour-search"
                className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900"
              >
                <svg {...iconProps} className="h-4 w-4 shrink-0">
                  <path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3" />
                </svg>
                <span className="truncate text-[13px]">Search projects, people, docs</span>
                <kbd className="ml-auto hidden shrink-0 rounded border border-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-400 dark:border-zinc-700 sm:inline">
                  /
                </kbd>
              </div>

              <button
                id="tour-new"
                className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-zinc-900 px-3 py-2 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white sm:px-3.5"
              >
                <svg {...iconProps} className="h-4 w-4" strokeWidth={2}>
                  <path d="M12 5v14M5 12h14" />
                </svg>
                New
              </button>

              <button
                id="tour-avatar"
                className="h-9 w-9 shrink-0 overflow-hidden rounded-full ring-1 ring-zinc-200 dark:ring-zinc-700"
                aria-label="Your profile"
              >
                <img
                  src="https://cdn.21st.dev/assets/mirror/a8/a8d7601d8ea95c50abee39faf78147b1cd0d7057881c0a95ec59176d848f968e.svg"
                  alt=""
                  className="h-full w-full"
                />
              </button>

              <button
                onClick={() => setDark((d) => !d)}
                aria-label="Toggle theme"
                className="h-9 w-9 shrink-0 rounded-full border border-zinc-200 text-zinc-500 transition-colors hover:bg-white dark:border-zinc-800 dark:hover:bg-zinc-900"
              >
                {dark ? "☀" : "☽"}
              </button>
            </header>

            <section className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
              <StatCard id="tour-revenue" label="Revenue" value="$48,210" delta="+12.4%" spark />
              <StatCard label="Active users" value="3,914" delta="+3.1%" />
              <StatCard label="Churn" value="1.8%" delta="-0.4%" />
            </section>

            <section className="mt-4 rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
              <div className="flex items-center justify-between">
                <h2 className="text-[13px] font-semibold">Recent activity</h2>
                <span className="text-[12px] text-zinc-400">Last 7 days</span>
              </div>
              <div className="mt-3 divide-y divide-zinc-100 dark:divide-zinc-800">
                {ROWS.map((r) => (
                  <div key={r.name} className="flex items-center gap-3 py-2.5">
                    <div className="h-7 w-7 shrink-0 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
                      <img src={`https://api.dicebear.com/9.x/notionists/svg?seed=${r.name}`} alt="" className="h-full w-full" />
                    </div>
                    <span className="text-[13px] font-medium">{r.name}</span>
                    <span className="text-[12.5px] text-zinc-400">{r.action}</span>
                    <span className="ml-auto text-[12px] tabular-nums text-zinc-400">{r.time}</span>
                  </div>
                ))}
              </div>
            </section>
          </main>
        </div>
      </div>

      <Tour
        steps={STEPS}
        open={tour.open}
        onOpenChange={tour.setOpen}
        index={tour.index}
        onIndexChange={tour.setIndex}
        onFinish={tour.markSeen}
      />
    </div>
  );
}

function NavItem({
  label,
  path,
  active,
  id,
}: {
  label: string;
  path: string;
  active?: boolean;
  id?: string;
}) {
  return (
    <button
      id={id}
      className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] transition-colors ${
        active
          ? "bg-zinc-100 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
          : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800/60 dark:hover:text-zinc-200"
      }`}
    >
      <svg {...iconProps} className="h-4 w-4">
        <path d={path} />
      </svg>
      {label}
    </button>
  );
}

function StatCard({
  id,
  label,
  value,
  delta,
  spark,
}: {
  id?: string;
  label: string;
  value: string;
  delta: string;
  spark?: boolean;
}) {
  const up = delta.trim().startsWith("+");
  return (
    <div
      id={id}
      className="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900"
    >
      <p className="text-[12px] text-zinc-400">{label}</p>
      <div className="mt-1 flex items-end justify-between">
        <span className="text-[22px] font-semibold tracking-tight">{value}</span>
        <span className={`text-[12px] font-medium ${up ? "text-zinc-900 dark:text-zinc-200" : "text-zinc-400"}`}>
          {delta}
        </span>
      </div>
      {spark && (
        <svg viewBox="0 0 120 28" className="mt-3 h-7 w-full text-zinc-300 dark:text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
          <path d="M0 22 L15 18 L30 20 L45 12 L60 15 L75 8 L90 11 L105 5 L120 7" />
        </svg>
      )}
    </div>
  );
}

const ROWS = [
  { name: "laziedev", action: "closed a deal", time: "2m" },
  { name: "shiawase", action: "invited 3 members", time: "18m" },
  { name: "khushi", action: "exported a report", time: "1h" },
  { name: "kiki", action: "created a workflow", time: "3h" },
];

Dependencies

More by Kiki

View all
  • Filter Token Bar

    Filter Token Bar

    522
  • Compose

    Compose

    423
  • Tier List Maker

    Tier List Maker

    138
  • Draggable Photo Stack

    Draggable Photo Stack

    84
  • Morphing Scroll Navbar

    Morphing Scroll Navbar

    81
  • Selection Bubble Menu

    Selection Bubble Menu

    35
  • 3D Before & After Image Slider

    3D Before & After Image Slider

    32
  • Advanced Data Table Filter Builder

    Advanced Data Table Filter Builder

    28