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
1 of 3
Search everything
Press the shortcut from anywhere to jump to a resource.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | TourStep[] | Each step has a target, a title and a body. target matches a data-tour attribute. | |
| onClose | () => void | Called on Done, Skip or Escape. Unmount the tour here. | |
| className | string | Applies 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