Skip to content
goodtabs UI
BrowseBrowse components

Onboarding tour

A step by step spotlight tour with keyboard control and Escape to end.

npx shadcn@latest add https://ui.goodtabs.co/r/onboarding-tour.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/onboarding-tour

Starts open inside a panel. Replay brings it back.

Started by a button

AllFreeOpen source
Fonts
Icons
Shaders
Palettes

The card flips above the target when there is no room below.

Usage

import { OnboardingTour } from "@/components/ui/onboarding-tour"

<div className="relative">
  <button data-tour="search">Search</button>
  {open && <OnboardingTour steps={steps} onClose={() => setOpen(false)} />}
</div>

Props

PropTypeDefaultDescription
stepsTourStep[]Each step has a target, a title and a body. target matches a data-tour attribute.
onClose() => voidCalled on Done, Skip or Escape. Unmount the tour here.
classNamestringApplies to the overlay.

Source

onboarding-tour/onboarding-tour.tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type TourStep = {
  /** Matches a data-tour attribute on the element to highlight. */
  target: string;
  title: string;
  body: string;
};

type OnboardingTourProps = {
  steps: TourStep[];
  /** Called when the tour finishes, is skipped or Escape is pressed. */
  onClose: () => void;
  className?: string;
};

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

const PAD = 6;
const CARD_WIDTH = 256;
const GAP = 12;
const EDGE = 8;

/**
 * A step by step spotlight tour. It fills its nearest positioned parent, so place it inside a
 * relative element: the page root for a full screen tour, or a panel for a contained one.
 * Escape ends it, arrow keys move, Tab stays inside the card. Mounting takes no focus of its
 * own: focus moves in only from a control inside that parent, the person's start button.
 */
function OnboardingTour({ steps, onClose, className }: OnboardingTourProps) {
  const [current, setCurrent] = React.useState(0);
  const [box, setBox] = React.useState<Box | null>(null);
  const [size, setSize] = React.useState({ width: 0, height: 0, card: 0, scrolls: false });
  const rootRef = React.useRef<HTMLDivElement>(null);
  const cardRef = React.useRef<HTMLDivElement>(null);
  const textRef = React.useRef<HTMLDivElement>(null);
  const stepped = React.useRef(false);
  // steps can shrink under the current position, so read it through a clamp.
  const index = Math.min(current, Math.max(0, steps.length - 1));
  const step = steps[index];
  const targetName = step?.target;
  const last = index === steps.length - 1;

  const measure = React.useCallback(() => {
    const root = rootRef.current;
    const parent = root?.parentElement;
    if (!root || !parent) return;
    const parentRect = parent.getBoundingClientRect();
    const text = textRef.current;
    setSize({
      width: parentRect.width,
      height: parentRect.height,
      card: cardRef.current?.offsetHeight ?? 0,
      scrolls: !!text && text.scrollHeight > text.clientHeight,
    });
    const target = parent.querySelector(`[data-tour="${targetName}"]`);
    if (!target) return setBox(null);
    const rect = target.getBoundingClientRect();
    setBox({
      top: rect.top - parentRect.top - PAD,
      left: rect.left - parentRect.left - PAD,
      width: rect.width + PAD * 2,
      height: rect.height + PAD * 2,
    });
  }, [targetName]);

  React.useLayoutEffect(() => {
    measure();
    const parent = rootRef.current?.parentElement;
    if (!parent) return;
    const observer = new ResizeObserver(measure);
    observer.observe(parent);
    if (cardRef.current) observer.observe(cardRef.current);
    window.addEventListener("resize", measure);
    return () => {
      observer.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, [measure, index]);

  // Opened from inside the parent, by a person: take focus. Mounted with the page: leave it alone.
  React.useEffect(() => {
    const parent = rootRef.current?.parentElement;
    const active = document.activeElement;
    if (parent && active !== document.body && parent.contains(active)) {
      cardRef.current?.focus({ preventScroll: true });
    }
  }, []);

  React.useEffect(() => {
    if (!stepped.current) return;
    stepped.current = false;
    cardRef.current?.focus({ preventScroll: true });
  }, [index]);

  function goTo(next: number) {
    stepped.current = true;
    setCurrent(next);
  }

  function onKeyDown(event: React.KeyboardEvent) {
    if (event.key === "Escape") {
      event.stopPropagation();
      onClose();
    } else if (event.key === "ArrowRight") {
      if (!last) goTo(index + 1);
    } else if (event.key === "ArrowLeft") {
      if (index > 0) goTo(index - 1);
    } else if (event.key === "Tab") {
      const focusable = cardRef.current?.querySelectorAll<HTMLElement>("button, [tabindex='0']");
      if (!focusable?.length) return;
      const first = focusable[0];
      const lastEl = focusable[focusable.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        lastEl.focus();
      } else if (!event.shiftKey && document.activeElement === lastEl) {
        event.preventDefault();
        first.focus();
      }
    }
  }

  if (!step) return null;

  const below = box ? box.top + box.height + GAP + size.card <= size.height : true;
  const wanted = box ? (below ? box.top + box.height + GAP : box.top - GAP - size.card) : (size.height - size.card) / 2;
  const cardTop = Math.max(EDGE, Math.min(wanted, size.height - size.card - EDGE));
  const cardLeft = Math.min(
    Math.max(8, box ? box.left : size.width / 2 - CARD_WIDTH / 2),
    Math.max(8, size.width - CARD_WIDTH - 8),
  );

  return (
    <div
      ref={rootRef}
      className={cn("absolute inset-0 z-50 overflow-hidden rounded-[inherit]", className)}
      onKeyDown={onKeyDown}
    >
      {box ? (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute rounded-lg shadow-[0_0_0_9999px_color-mix(in_oklab,var(--background)_78%,transparent)] ring-1 ring-foreground/40"
          style={{ top: box.top, left: box.left, width: box.width, height: box.height }}
        />
      ) : (
        <div aria-hidden="true" className="absolute inset-0 bg-background/80" />
      )}
      <div
        ref={cardRef}
        role="dialog"
        aria-label={`Tour step ${index + 1} of ${steps.length}: ${step.title}`}
        tabIndex={-1}
        className="absolute flex max-h-[calc(100%-1rem)] flex-col gap-3 rounded-xl border bg-popover p-3 text-popover-foreground shadow-lg outline-none"
        style={{ top: cardTop, left: cardLeft, width: CARD_WIDTH }}
      >
        <div
          ref={textRef}
          className="flex min-h-0 flex-col gap-1 overflow-y-auto outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
          {...(size.scrolls ? { tabIndex: 0, role: "region", "aria-label": "Step text" } : {})}
        >
          <p className="font-mono text-xs text-muted-foreground">
            {index + 1} of {steps.length}
          </p>
          <h2 className="text-sm font-medium">{step.title}</h2>
          <p className="text-sm text-muted-foreground">{step.body}</p>
        </div>
        <div className="flex items-center justify-between gap-2">
          <Button variant="ghost" size="sm" onClick={onClose}>
            Skip
          </Button>
          <div className="flex gap-2">
            {index > 0 ? (
              <Button variant="outline" size="sm" onClick={() => goTo(index - 1)}>
                Back
              </Button>
            ) : null}
            <Button size="sm" onClick={() => (last ? onClose() : goTo(index + 1))}>
              {last ? "Done" : "Next"}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

export { OnboardingTour, type TourStep };

For AI agents

Hand your agent this page, the markdown at /components/onboarding-tour/prompt.md, or the Copy AI prompt button above.

When to use

  • Showing a new person three to five controls once, on first visit
  • Introducing a feature after a release

When not to use

  • Long product explanations: write docs instead
  • Every visit: remember that it was seen and do not repeat it

Agent notes

  • The overlay fills its nearest positioned parent, so put it inside a relative element
  • Targets are found with querySelector on data-tour inside that parent
  • Mounting takes no focus, so a tour that starts open leaves focus and scroll alone
  • Focus moves to the step card on each step, and on open only when focus is already inside the parent, so keep the start button in there
  • Tab cycles inside the card; Escape, Skip and Done all call onClose
  • Arrow keys move between steps
  • No animation, so reduced motion needs no special case