Web InnoventixFreeCode

Onboarding Tooltip Tour

Original · free

guided tooltip walkthrough

byWeb InnoventixReact + Tailwind
onboardtooltiptouronboarding
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/onboard-tooltip-tour.json
onboard-tooltip-tour.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Placement = "top" | "bottom" | "left" | "right";

type TourStep = {
  id: string;
  target: string;
  title: string;
  body: string;
  placement: Placement;
  hint: string;
};

const STEPS: TourStep[] = [
  {
    id: "search",
    target: "search",
    title: "Search across every workspace",
    body:
      "Type a customer name, invoice number, or ticket ID. Results stream in from Billing, Support, and Analytics at once — no tab switching.",
    placement: "bottom",
    hint: "Shortcut: press / anywhere",
  },
  {
    id: "filters",
    target: "filters",
    title: "Filters stack, they don't replace",
    body:
      "Each chip narrows the set instead of resetting it. Click a chip twice to invert it — handy for finding everything that isn't overdue.",
    placement: "bottom",
    hint: "Alt-click a chip to solo it",
  },
  {
    id: "table",
    target: "table",
    title: "Rows are editable in place",
    body:
      "Double-click any cell to edit without opening a drawer. Changes save on blur and appear in your teammates' views in under two seconds.",
    placement: "top",
    hint: "Undo with Cmd+Z for 30 seconds",
  },
  {
    id: "invite",
    target: "invite",
    title: "Bring in the rest of your team",
    body:
      "Invited teammates land on this exact view with the same filters applied. Viewers are free — you only pay for people who edit.",
    placement: "left",
    hint: "Seats billed monthly, prorated",
  },
];

type Rect = { top: number; left: number; width: number; height: number };

const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);

export default function OnboardTooltipTour() {
  const reduceMotion = useReducedMotion();
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");

  const [running, setRunning] = useState(false);
  const [index, setIndex] = useState(0);
  const [rect, setRect] = useState<Rect | null>(null);
  const [stageWidth, setStageWidth] = useState(0);
  const [done, setDone] = useState<string[]>([]);

  const stageRef = useRef<HTMLDivElement | null>(null);
  const cardRef = useRef<HTMLDivElement | null>(null);
  const startRef = useRef<HTMLButtonElement | null>(null);
  const nextRef = useRef<HTMLButtonElement | null>(null);

  const step = STEPS[index];
  const isLast = index === STEPS.length - 1;

  const measure = useCallback(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const el = stage.querySelector<HTMLElement>(`[data-tour-target="${STEPS[index].target}"]`);
    if (!el) return;
    const s = stage.getBoundingClientRect();
    const t = el.getBoundingClientRect();
    setStageWidth(s.width);
    setRect({
      top: t.top - s.top,
      left: t.left - s.left,
      width: t.width,
      height: t.height,
    });
  }, [index]);

  useEffect(() => {
    if (!running) return;
    measure();
  }, [running, measure]);

  useEffect(() => {
    if (!running) return;
    const onResize = () => measure();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [running, measure]);

  useEffect(() => {
    if (!running) return;
    const id = window.setTimeout(() => nextRef.current?.focus(), reduceMotion ? 0 : 180);
    return () => window.clearTimeout(id);
  }, [running, index, reduceMotion]);

  const stop = useCallback(() => {
    setRunning(false);
    setRect(null);
    window.setTimeout(() => startRef.current?.focus(), 0);
  }, []);

  const goNext = useCallback(() => {
    setDone((prev) => (prev.includes(STEPS[index].id) ? prev : [...prev, STEPS[index].id]));
    if (index === STEPS.length - 1) {
      stop();
      setIndex(0);
      return;
    }
    setIndex((i) => i + 1);
  }, [index, stop]);

  const goPrev = useCallback(() => {
    setIndex((i) => Math.max(0, i - 1));
  }, []);

  useEffect(() => {
    if (!running) return;
    const onKey = (e: KeyboardEvent) => {
      const target = e.target;
      const typing =
        target instanceof HTMLElement &&
        (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);

      if (e.key === "Escape") {
        e.preventDefault();
        stop();
      } else if (e.key === "ArrowRight" && !typing) {
        e.preventDefault();
        goNext();
      } else if (e.key === "ArrowLeft" && !typing) {
        e.preventDefault();
        goPrev();
      } else if (e.key === "Tab") {
        const card = cardRef.current;
        if (!card) return;
        const focusables = card.querySelectorAll<HTMLElement>(
          'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
        );
        if (focusables.length === 0) return;
        const first = focusables[0];
        const last = focusables[focusables.length - 1];
        const active = document.activeElement;
        if (e.shiftKey && (active === first || !card.contains(active))) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && active === last) {
          e.preventDefault();
          first.focus();
        }
      }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [running, stop, goNext, goPrev]);

  const start = () => {
    setIndex(0);
    setDone([]);
    setRunning(true);
  };

  const cardPos = useMemo(() => {
    if (!rect || stageWidth === 0) return null;
    const gap = 14;
    const pad = 8;
    const cardW = Math.min(336, stageWidth - pad * 2);
    const maxLeft = Math.max(pad, stageWidth - cardW - pad);

    // Side placements need horizontal room for the card; fall back to stacking when narrow.
    const sideFits = stageWidth >= 640;
    const placement: Placement =
      !sideFits && (step.placement === "left" || step.placement === "right") ? "bottom" : step.placement;

    switch (placement) {
      case "top":
        return {
          top: rect.top - gap,
          left: clamp(rect.left, pad, maxLeft),
          width: cardW,
          flip: true,
          origin: "bottom left",
        };
      case "left":
        return {
          top: rect.top,
          left: clamp(rect.left - gap - cardW, pad, maxLeft),
          width: cardW,
          flip: false,
          origin: "top right",
        };
      case "right":
        return {
          top: rect.top,
          left: clamp(rect.left + rect.width + gap, pad, maxLeft),
          width: cardW,
          flip: false,
          origin: "top left",
        };
      default:
        return {
          top: rect.top + rect.height + gap,
          left: clamp(rect.left, pad, maxLeft),
          width: cardW,
          flip: false,
          origin: "top left",
        };
    }
  }, [rect, stageWidth, step.placement]);

  const transition = reduceMotion
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 420, damping: 34, mass: 0.7 };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes ott-${uid}-pulse {
          0%, 100% { box-shadow: 0 0 0 0 rgba(99,102,241,0.45); }
          50% { box-shadow: 0 0 0 8px rgba(99,102,241,0); }
        }
        @keyframes ott-${uid}-sheen {
          from { transform: translateX(-120%); }
          to { transform: translateX(220%); }
        }
        .ott-${uid}-pulse { animation: ott-${uid}-pulse 2.2s ease-out infinite; }
        .ott-${uid}-sheen::after {
          content: "";
          position: absolute;
          inset: 0;
          background: linear-gradient(100deg, transparent 35%, rgba(255,255,255,0.35) 50%, transparent 65%);
          animation: ott-${uid}-sheen 2.6s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .ott-${uid}-pulse, .ott-${uid}-sheen::after { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Product tour
            </p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
              Four things worth knowing before you start
            </h2>
            <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              A short walkthrough of the console. Use the arrow keys to move between steps, or press Escape to leave at
              any point — the tour remembers nothing and judges no one.
            </p>
          </div>
          <button
            ref={startRef}
            type="button"
            onClick={running ? stop : start}
            className="relative shrink-0 overflow-hidden rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950"
          >
            {running ? "End tour" : done.length === STEPS.length ? "Replay tour" : "Start tour"}
          </button>
        </header>

        <div
          ref={stageRef}
          className="relative rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
        >
          {/* Mock app chrome */}
          <div className="flex flex-col gap-3 border-b border-slate-200 p-4 sm:flex-row sm:items-center sm:gap-4 dark:border-slate-800">
            <div className="flex items-center gap-2">
              <span className="grid h-7 w-7 place-items-center rounded-md bg-indigo-600 text-[11px] font-bold text-white">
                MW
              </span>
              <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Meridian</span>
            </div>

            <div
              data-tour-target="search"
              className={`relative flex-1 rounded-lg ${
                running && step.target === "search" ? `ott-${uid}-pulse` : ""
              }`}
            >
              <label htmlFor={`${uid}-search`} className="sr-only">
                Search records
              </label>
              <svg
                aria-hidden="true"
                viewBox="0 0 20 20"
                className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.6"
              >
                <circle cx="9" cy="9" r="5.5" />
                <path d="m13.2 13.2 3.3 3.3" strokeLinecap="round" />
              </svg>
              <input
                id={`${uid}-search`}
                type="search"
                placeholder="Search invoices, tickets, accounts"
                className="w-full rounded-lg border border-slate-200 bg-slate-50 py-2 pl-8 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
              />
            </div>

            <div
              data-tour-target="invite"
              className={running && step.target === "invite" ? `ott-${uid}-pulse rounded-lg` : "rounded-lg"}
            >
              <button
                type="button"
                className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 sm:w-auto dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
              >
                <svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6">
                  <circle cx="8" cy="6.5" r="3" />
                  <path d="M2.5 16c0-2.8 2.5-4.5 5.5-4.5M15 8.5v5M17.5 11h-5" strokeLinecap="round" />
                </svg>
                Invite
              </button>
            </div>
          </div>

          <div className="p-4">
            <div
              data-tour-target="filters"
              className={`flex flex-wrap gap-2 rounded-lg ${running && step.target === "filters" ? `ott-${uid}-pulse` : ""}`}
            >
              {["Overdue", "This quarter", "Enterprise", "Assigned to me"].map((chip, i) => (
                <span
                  key={chip}
                  className={`rounded-full border px-3 py-1 text-xs font-medium ${
                    i === 0
                      ? "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900 dark:bg-indigo-950 dark:text-indigo-300"
                      : "border-slate-200 bg-white text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
                  }`}
                >
                  {chip}
                </span>
              ))}
            </div>

            <div
              data-tour-target="table"
              className={`mt-4 overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-800 ${
                running && step.target === "table" ? `ott-${uid}-pulse` : ""
              }`}
            >
              <table className="w-full min-w-[34rem] border-collapse text-left text-sm">
                <caption className="sr-only">Recent invoices</caption>
                <thead>
                  <tr className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500 dark:bg-slate-950 dark:text-slate-500">
                    <th scope="col" className="px-3 py-2 font-semibold">Account</th>
                    <th scope="col" className="px-3 py-2 font-semibold">Invoice</th>
                    <th scope="col" className="px-3 py-2 font-semibold">Amount</th>
                    <th scope="col" className="px-3 py-2 font-semibold">Status</th>
                  </tr>
                </thead>
                <tbody className="divide-y divide-slate-200 dark:divide-slate-800">
                  {[
                    ["Halvorsen Logistics", "INV-40912", "$18,400", "Overdue 6d", "rose"],
                    ["Bright Fern Studio", "INV-40908", "$2,150", "Paid", "emerald"],
                    ["Kestrel Analytics", "INV-40901", "$9,720", "Due in 3d", "amber"],
                  ].map(([account, inv, amount, status, tone]) => (
                    <tr key={inv} className="text-slate-700 dark:text-slate-300">
                      <td className="px-3 py-2.5 font-medium text-slate-900 dark:text-slate-100">{account}</td>
                      <td className="px-3 py-2.5 tabular-nums">{inv}</td>
                      <td className="px-3 py-2.5 tabular-nums">{amount}</td>
                      <td className="px-3 py-2.5">
                        <span
                          className={
                            tone === "rose"
                              ? "rounded-full bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-950 dark:text-rose-300"
                              : tone === "emerald"
                                ? "rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
                                : "rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-950 dark:text-amber-300"
                          }
                        >
                          {status}
                        </span>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>

          {/* Spotlight */}
          <AnimatePresence>
            {running && rect ? (
              <motion.div
                key="scrim"
                aria-hidden="true"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reduceMotion ? 0 : 0.2 }}
                className="pointer-events-none absolute inset-0 z-10 rounded-2xl"
                style={{
                  background: "rgba(15,23,42,0.55)",
                  clipPath: `polygon(0% 0%, 0% 100%, ${rect.left - 6}px 100%, ${rect.left - 6}px ${rect.top - 6}px, ${
                    rect.left + rect.width + 6
                  }px ${rect.top - 6}px, ${rect.left + rect.width + 6}px ${rect.top + rect.height + 6}px, ${
                    rect.left - 6
                  }px ${rect.top + rect.height + 6}px, ${rect.left - 6}px 100%, 100% 100%, 100% 0%)`,
                }}
              />
            ) : null}
          </AnimatePresence>

          {running && rect ? (
            <motion.div
              aria-hidden="true"
              initial={false}
              animate={{ top: rect.top - 6, left: rect.left - 6, width: rect.width + 12, height: rect.height + 12 }}
              transition={transition}
              className={`pointer-events-none absolute z-20 overflow-hidden rounded-xl border-2 border-indigo-400 ott-${uid}-sheen`}
            />
          ) : null}

          {/* Tooltip card */}
          {running && cardPos ? (
            <div
              className="absolute z-30"
              style={{
                top: cardPos.top,
                left: cardPos.left,
                width: cardPos.width,
                transform: cardPos.flip ? "translateY(-100%)" : undefined,
              }}
            >
              <motion.div
                key={step.id}
                ref={cardRef}
                role="dialog"
                aria-labelledby={`${uid}-title`}
                aria-describedby={`${uid}-body`}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
                animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
                transition={reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                style={{ transformOrigin: cardPos.origin }}
                className="w-full rounded-xl border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-900"
              >
                <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
                  Step {index + 1} of {STEPS.length}
                </p>
                <h3 id={`${uid}-title`} className="mt-1 text-base font-semibold text-slate-900 dark:text-slate-50">
                  {step.title}
                </h3>
                <p id={`${uid}-body`} className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                  {step.body}
                </p>
                <p className="mt-2.5 rounded-md bg-slate-50 px-2.5 py-1.5 text-xs text-slate-500 dark:bg-slate-950 dark:text-slate-400">
                  {step.hint}
                </p>

                <div className="mt-4 flex items-center justify-between gap-3">
                  <div className="flex gap-1.5" aria-hidden="true">
                    {STEPS.map((s, i) => (
                      <span
                        key={s.id}
                        className={`h-1.5 rounded-full transition-all ${
                          i === index
                            ? "w-5 bg-indigo-600 dark:bg-indigo-400"
                            : "w-1.5 bg-slate-300 dark:bg-slate-700"
                        }`}
                      />
                    ))}
                  </div>
                  <div className="flex items-center gap-2">
                    <button
                      type="button"
                      onClick={stop}
                      className="rounded-md px-2 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:text-slate-100"
                    >
                      Skip
                    </button>
                    <button
                      type="button"
                      onClick={goPrev}
                      disabled={index === 0}
                      className="rounded-md border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
                    >
                      Back
                    </button>
                    <button
                      ref={nextRef}
                      type="button"
                      onClick={goNext}
                      className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                    >
                      {isLast ? "Finish" : "Next"}
                    </button>
                  </div>
                </div>
              </motion.div>
            </div>
          ) : null}
        </div>

        <p aria-live="polite" className="sr-only">
          {running ? `Step ${index + 1} of ${STEPS.length}: ${step.title}. ${step.body}` : "Tour is not running."}
        </p>

        <p className="mt-4 text-xs text-slate-500 dark:text-slate-500">
          {done.length === STEPS.length
            ? "Tour complete — every step covered."
            : `${done.length} of ${STEPS.length} steps seen. Arrow keys navigate, Escape exits.`}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →