Tour
A shadcn-style tour component built with Ark UI primitives.
0 of 4
export { default } from "./tour-basic";
Installation
npx shadcn@latest add @ark-cn/tourInstall the dependency required by this primitive:
npm install @ark-ui/react lucide-reactCopy the component source into your app:
TSXcomponents/ui/tour.tsx
"use client";
import { Portal } from "@ark-ui/react/portal";
import {
Tour as TourPrimitive,
useTour,
waitForElement,
waitForEvent,
} from "@ark-ui/react/tour";
import { PlayIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TourProps = TourPrimitive.RootProps;
export const Tour = (props: TourProps) => (
<TourPrimitive.Root data-slot="tour" {...props} />
);
export type TourPopupProps = TourPrimitive.ContentProps & {
arrowClassName?: string;
arrowTipClassName?: string;
disablePortal?: boolean;
positionerClassName?: string;
showArrow?: boolean;
};
export const TourPopup = ({
arrowClassName,
arrowTipClassName,
children,
className,
disablePortal,
positionerClassName,
showArrow = true,
...contentProps
}: TourPopupProps) => {
const inner = (
<TourPrimitive.Positioner
className={cn(
!disablePortal && "z-50",
"data-[type=tooltip]:z-50",
"data-[type=dialog]:fixed data-[type=dialog]:inset-0 data-[type=dialog]:flex data-[type=dialog]:items-center data-[type=dialog]:justify-center",
"data-[type=floating]:fixed data-[type=floating]:z-50",
"[&[data-type=floating][data-placement*=top]]:top-6",
"[&[data-type=floating][data-placement*=bottom]]:bottom-6",
"[&[data-type=floating][data-placement*=start]]:left-6",
"[&[data-type=floating][data-placement*=end]]:right-6",
positionerClassName,
)}
data-slot="tour-positioner"
>
<TourPrimitive.Content
className={cn(
"relative z-[calc(50+var(--layer-index,0))] w-[min(20rem,var(--available-width,20rem))] max-w-[min(20rem,var(--available-width,20rem))] rounded-xl border border-border/80 bg-popover p-4 text-popover-foreground shadow-md outline-none ring-1 ring-border/20",
"origin-(--transform-origin) transition-opacity duration-150 data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
"data-[type=dialog]:w-[min(24rem,calc(100vw-2rem))]",
"data-[type=floating]:w-[min(22rem,calc(100vw-2rem))]",
className,
)}
data-slot="tour-content"
{...contentProps}
>
{children}
{showArrow ? (
<TourPrimitive.Arrow
className={cn(
"[--arrow-background:var(--popover)] [--arrow-size:10px] [--arrow-shadow-color:var(--border)]",
arrowClassName,
)}
data-slot="tour-arrow"
>
<TourPrimitive.ArrowTip
className={cn(
"border-border border-t border-l",
arrowTipClassName,
)}
data-slot="tour-arrow-tip"
/>
</TourPrimitive.Arrow>
) : null}
</TourPrimitive.Content>
</TourPrimitive.Positioner>
);
return disablePortal ? inner : <Portal>{inner}</Portal>;
};
export type TourBackdropProps = TourPrimitive.BackdropProps;
export const TourBackdrop = ({ className, ...props }: TourBackdropProps) => (
<TourPrimitive.Backdrop
className={cn(
"fixed inset-0 z-50 bg-black/55 transition-opacity duration-150 data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
className,
)}
data-slot="tour-backdrop"
{...props}
/>
);
export type TourSpotlightProps = TourPrimitive.SpotlightProps;
export const TourSpotlight = ({ className, ...props }: TourSpotlightProps) => (
<TourPrimitive.Spotlight
className={cn(
"z-50 rounded-xl border border-white/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.2)]",
className,
)}
data-slot="tour-spotlight"
{...props}
/>
);
export type TourActionTriggerProps = TourPrimitive.ActionTriggerProps;
export const TourActionTrigger = ({
className,
...props
}: TourActionTriggerProps) => (
<TourPrimitive.ActionTrigger
className={cn(
"inline-flex h-9 cursor-pointer items-center justify-center rounded-lg border border-input bg-background px-3 font-medium text-foreground text-sm shadow-xs/5 outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-disabled:pointer-events-none data-disabled:opacity-64 sm:h-8",
className,
)}
data-slot="tour-action-trigger"
{...props}
/>
);
export type TourActionsProps = TourPrimitive.ActionsProps;
export const TourActions = (props: TourActionsProps) => (
<TourPrimitive.Actions data-slot="tour-actions" {...props} />
);
export type TourCloseTriggerProps = TourPrimitive.CloseTriggerProps;
export const TourCloseTrigger = ({
className,
...props
}: TourCloseTriggerProps) => (
<TourPrimitive.CloseTrigger
className={cn(
"inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-disabled:pointer-events-none data-disabled:opacity-64",
className,
)}
data-slot="tour-close-trigger"
{...props}
/>
);
export type TourControlProps = TourPrimitive.ControlProps;
export const TourControl = ({ className, ...props }: TourControlProps) => (
<TourPrimitive.Control
className={cn(
"mt-4 flex flex-wrap items-center justify-end gap-2",
className,
)}
data-slot="tour-control"
{...props}
/>
);
export type TourTitleProps = TourPrimitive.TitleProps;
export const TourTitle = ({ className, ...props }: TourTitleProps) => (
<TourPrimitive.Title
className={cn("font-semibold text-base text-foreground", className)}
data-slot="tour-title"
{...props}
/>
);
export type TourDescriptionProps = TourPrimitive.DescriptionProps;
export const TourDescription = ({
className,
...props
}: TourDescriptionProps) => (
<TourPrimitive.Description
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
data-slot="tour-description"
{...props}
/>
);
export type TourProgressTextProps = TourPrimitive.ProgressTextProps;
export const TourProgressText = ({
className,
...props
}: TourProgressTextProps) => (
<TourPrimitive.ProgressText
className={cn("mb-2 text-muted-foreground text-xs", className)}
data-slot="tour-progress-text"
{...props}
/>
);
export type TourPositionerProps = TourPrimitive.PositionerProps;
export const TourPositioner = ({
className,
...props
}: TourPositionerProps) => (
<TourPrimitive.Positioner
className={cn(className)}
data-slot="tour-positioner-raw"
{...props}
/>
);
export type TourContentProps = TourPrimitive.ContentProps;
export const TourContent = ({ className, ...props }: TourContentProps) => (
<TourPrimitive.Content
className={cn(className)}
data-slot="tour-content-raw"
{...props}
/>
);
export type TourArrowProps = TourPrimitive.ArrowProps;
export const TourArrow = ({ className, ...props }: TourArrowProps) => (
<TourPrimitive.Arrow
className={cn(className)}
data-slot="tour-arrow-raw"
{...props}
/>
);
export type TourArrowTipProps = TourPrimitive.ArrowTipProps;
export const TourArrowTip = ({ className, ...props }: TourArrowTipProps) => (
<TourPrimitive.ArrowTip
className={cn(className)}
data-slot="tour-arrow-tip-raw"
{...props}
/>
);
export const tourStageClass = "mx-auto flex w-full max-w-md flex-col gap-3";
export const tourTargetClass =
"flex min-h-20 items-center justify-center rounded-xl border border-border/70 bg-muted/20 px-4 py-3 text-center font-medium text-foreground text-sm";
export type TourLaunchButtonProps = {
label?: string;
onClick: () => void;
};
export const TourLaunchButton = ({
label = "Start tour",
onClick,
}: TourLaunchButtonProps) => (
<Button
className="w-fit gap-2"
onClick={onClick}
type="button"
variant="outline"
>
<PlayIcon className="size-4" />
{label}
</Button>
);
export const TourActionButtons = () => (
<TourActions>
{(actions) =>
actions.map((action, index) => (
<TourActionTrigger
action={action}
asChild
key={`${action.label}-${index}`}
>
<Button
size="sm"
type="button"
variant={
action.action === "next" || action.action === "dismiss"
? "default"
: "outline"
}
>
{action.label}
</Button>
</TourActionTrigger>
))
}
</TourActions>
);
export type TourFrameProps = {
showProgressBar?: boolean;
tour: ReturnType<typeof useTour>;
};
export const TourFrame = ({
tour,
showProgressBar = false,
}: TourFrameProps) => (
<Tour tour={tour}>
<Portal>
<TourBackdrop />
<TourSpotlight />
</Portal>
<TourPopup className={cn("space-y-2", showProgressBar && "pb-6")}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<TourProgressText />
<TourTitle />
</div>
<TourCloseTrigger asChild>
<Button
aria-label="Close tour"
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
</TourCloseTrigger>
</div>
<TourDescription />
<TourControl>
<TourActionButtons />
</TourControl>
{showProgressBar ? (
<div className="absolute inset-x-0 bottom-0 h-1 overflow-hidden rounded-b-xl bg-muted">
<div
className="h-full bg-primary transition-[width] duration-200"
style={{ width: `${tour.getProgressPercent()}%` }}
/>
</div>
) : null}
</TourPopup>
</Tour>
);
export { useTour, waitForElement, waitForEvent };
Update import aliases to match your project setup.
Usage
import * as Tour from "@/components/ui/tour"Read exported parts in src/components/ui/tour.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Basic
0 of 4
import { Button } from "@/components/ui/button";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
useTour,
} from "@/components/ui/tour";
const TourBasicDemo = () => {
const tour = useTour({
steps: [
{
id: "welcome",
type: "dialog",
title: "Welcome aboard",
description:
"This starter tour introduces the main actions in the workspace.",
actions: [{ label: "Start tour", action: "next" }],
},
{
id: "upload",
type: "tooltip",
arrow: true,
title: "Upload files",
description: "Add assets or docs from your computer.",
target: () => document.querySelector("#tour-basic-upload"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "save",
type: "tooltip",
arrow: true,
title: "Save changes",
description: "Persist the current draft before sharing it.",
target: () => document.querySelector("#tour-basic-save"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "more",
type: "tooltip",
arrow: true,
title: "More options",
description: "Advanced actions stay tucked away in the overflow menu.",
target: () => document.querySelector("#tour-basic-more"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton onClick={() => tour.start()} />
<div className="flex flex-wrap gap-2">
<Button id="tour-basic-upload" type="button" variant="outline">
Upload
</Button>
<Button id="tour-basic-save" type="button" variant="outline">
Save
</Button>
<Button id="tour-basic-more" type="button" variant="outline">
More
</Button>
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourBasicDemo;
Mixed Types
Product analytics card
0 of 4
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
import { cn } from "@/lib/utils";
const TourMixedTypesDemo = () => {
const tour = useTour({
steps: [
{
id: "intro",
type: "dialog",
title: "Three step types",
description:
"This sequence mixes dialog, tooltip, and floating tour steps in one flow.",
actions: [{ label: "Start tour", action: "next" }],
},
{
id: "anchor",
type: "tooltip",
arrow: true,
title: "Anchored tooltip",
description: "Tooltips are perfect for calling out a concrete control.",
target: () => document.querySelector("#tour-mixed-anchor"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "floating",
type: "floating",
placement: "bottom-end",
title: "Floating panel",
description:
"Floating steps can introduce ambient UI without needing a target node.",
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "done",
type: "dialog",
title: "Done",
description: "You’ve now seen the core Tour presentation modes.",
actions: [{ label: "Close", action: "dismiss" }],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton onClick={() => tour.start()} />
<div className={cn(tourTargetClass, "max-w-sm")} id="tour-mixed-anchor">
Product analytics card
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourMixedTypesDemo;
Progress Bar
Step 1
Step 2
Step 3
Step 4
0 of 4
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
const TourProgressBarDemo = () => {
const tour = useTour({
steps: [
{
id: "step-1",
type: "tooltip",
arrow: true,
title: "Step one",
description: "The progress bar fills as you move through the tour.",
target: () => document.querySelector("#tour-progress-1"),
actions: [{ label: "Next", action: "next" }],
},
{
id: "step-2",
type: "tooltip",
arrow: true,
title: "Step two",
description: "Halfway markers help users understand remaining effort.",
target: () => document.querySelector("#tour-progress-2"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "step-3",
type: "tooltip",
arrow: true,
title: "Step three",
description: "A compact bar fits nicely inside the popup chrome.",
target: () => document.querySelector("#tour-progress-3"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "step-4",
type: "tooltip",
arrow: true,
title: "All set",
description: "Last step reached.",
target: () => document.querySelector("#tour-progress-4"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton onClick={() => tour.start()} />
<div className="grid grid-cols-2 gap-2">
{["1", "2", "3", "4"].map((step) => (
<div
className={tourTargetClass}
id={`tour-progress-${step}`}
key={step}
>
Step {step}
</div>
))}
</div>
<TourFrame showProgressBar tour={tour} />
</div>
);
};
export default TourProgressBarDemo;
Skip
Collections
Views
Shortcuts
0 of 3
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
const TourSkipDemo = () => {
const tour = useTour({
steps: [
{
id: "step-1",
type: "tooltip",
arrow: true,
title: "Collections",
description: "Users can skip the tour from the very first step.",
target: () => document.querySelector("#tour-skip-1"),
actions: [
{ label: "Skip", action: "dismiss" },
{ label: "Next", action: "next" },
],
},
{
id: "step-2",
type: "tooltip",
arrow: true,
title: "Saved views",
description:
"Keep skip available even mid-tour so exits are frictionless.",
target: () => document.querySelector("#tour-skip-2"),
actions: [
{ label: "Skip", action: "dismiss" },
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "step-3",
type: "tooltip",
arrow: true,
title: "Shortcuts",
description: "The last step can finish normally.",
target: () => document.querySelector("#tour-skip-3"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton onClick={() => tour.start()} />
<div className="grid gap-2 sm:grid-cols-3">
<div className={tourTargetClass} id="tour-skip-1">
Collections
</div>
<div className={tourTargetClass} id="tour-skip-2">
Views
</div>
<div className={tourTargetClass} id="tour-skip-3">
Shortcuts
</div>
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourSkipDemo;
Keyboard Navigation
Use Right Arrow, Left Arrow, and Escape while the tour is open.
Right
Left
Escape
0 of 3
import { InfoIcon } from "lucide-react";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
const TourKeyboardNavigationDemo = () => {
const tour = useTour({
keyboardNavigation: true,
steps: [
{
id: "step-1",
type: "tooltip",
arrow: true,
title: "Arrow keys",
description: "Press Right Arrow to move forward.",
target: () => document.querySelector("#tour-keyboard-1"),
actions: [{ label: "Next", action: "next" }],
},
{
id: "step-2",
type: "tooltip",
arrow: true,
title: "Go back",
description: "Press Left Arrow to revisit the previous item.",
target: () => document.querySelector("#tour-keyboard-2"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "step-3",
type: "tooltip",
arrow: true,
title: "Escape closes",
description: "Escape dismisses the tour from anywhere.",
target: () => document.querySelector("#tour-keyboard-3"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton onClick={() => tour.start()} />
<p className="inline-flex items-center gap-2 text-muted-foreground text-xs">
<InfoIcon className="size-4" />
Use Right Arrow, Left Arrow, and Escape while the tour is open.
</p>
<div className="grid gap-2 sm:grid-cols-3">
<div className={tourTargetClass} id="tour-keyboard-1">
Right
</div>
<div className={tourTargetClass} id="tour-keyboard-2">
Left
</div>
<div className={tourTargetClass} id="tour-keyboard-3">
Escape
</div>
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourKeyboardNavigationDemo;
Events
Trigger 1
Trigger 2
Trigger 3
Event log
Start the tour to see lifecycle events.
0 of 3
import { useState } from "react";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
const TourEventsDemo = () => {
const [logs, setLogs] = useState<string[]>([]);
const pushLog = (message: string) => {
setLogs((current) => [...current.slice(-5), message]);
};
const tour = useTour({
steps: [
{
id: "step-1",
type: "tooltip",
arrow: true,
title: "Event stream",
description: "Every step change writes to the log.",
target: () => document.querySelector("#tour-events-1"),
actions: [{ label: "Next", action: "next" }],
},
{
id: "step-2",
type: "tooltip",
arrow: true,
title: "Lifecycle hooks",
description: "Status changes are useful for analytics and onboarding.",
target: () => document.querySelector("#tour-events-2"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
id: "step-3",
type: "tooltip",
arrow: true,
title: "Finish event",
description: "Dismiss the tour to emit the closed status.",
target: () => document.querySelector("#tour-events-3"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
],
onStepChange: (details) => {
pushLog(`step -> ${details.stepId}`);
},
onStatusChange: (details) => {
pushLog(`status -> ${details.status}`);
},
});
return (
<div className={tourStageClass}>
<TourLaunchButton
label="Start tracked tour"
onClick={() => {
setLogs([]);
tour.start();
}}
/>
<div className="grid gap-2 sm:grid-cols-3">
<div className={tourTargetClass} id="tour-events-1">
Trigger 1
</div>
<div className={tourTargetClass} id="tour-events-2">
Trigger 2
</div>
<div className={tourTargetClass} id="tour-events-3">
Trigger 3
</div>
</div>
<div className="rounded-xl border border-border/70 bg-muted/15 p-3 text-xs">
<p className="mb-2 font-medium text-foreground">Event log</p>
<div className="space-y-1 font-mono text-muted-foreground">
{logs.length ? (
logs.map((log, index) => <p key={`${log}-${index}`}>{log}</p>)
) : (
<p>Start the tour to see lifecycle events.</p>
)}
</div>
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourEventsDemo;
Wait For Click
Last action: none
0 of 5
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
useTour,
waitForEvent,
} from "@/components/ui/tour";
const TourWaitForClickDemo = () => {
const [lastAction, setLastAction] = useState("none");
const tour = useTour({
steps: [
{
id: "intro",
type: "dialog",
title: "Interactive tour",
description:
"These steps wait for a real click before advancing automatically.",
actions: [{ label: "Begin", action: "next" }],
},
{
id: "create",
type: "tooltip",
arrow: true,
title: "Click Create",
description: "Press the Create button to continue.",
target: () => document.querySelector("#tour-click-create"),
effect: ({ next, target, show }) => {
show();
const [promise, cancel] = waitForEvent(target, "click");
promise.then(() => next());
return cancel;
},
},
{
id: "publish",
type: "tooltip",
arrow: true,
title: "Click Publish",
description: "One more required action.",
target: () => document.querySelector("#tour-click-publish"),
effect: ({ next, target, show }) => {
show();
const [promise, cancel] = waitForEvent(target, "click");
promise.then(() => next());
return cancel;
},
},
{
id: "archive",
type: "tooltip",
arrow: true,
title: "Click Archive",
description: "The last interaction completes the guided path.",
target: () => document.querySelector("#tour-click-archive"),
effect: ({ next, target, show }) => {
show();
const [promise, cancel] = waitForEvent(target, "click");
promise.then(() => next());
return cancel;
},
},
{
id: "done",
type: "dialog",
title: "Workflow complete",
description: "You triggered every required action.",
actions: [{ label: "Close", action: "dismiss" }],
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton
label="Start interactive tour"
onClick={() => tour.start()}
/>
<div className="flex flex-wrap gap-2">
<Button
id="tour-click-create"
onClick={() => setLastAction("create")}
type="button"
variant="outline"
>
Create
</Button>
<Button
id="tour-click-publish"
onClick={() => setLastAction("publish")}
type="button"
variant="outline"
>
Publish
</Button>
<Button
id="tour-click-archive"
onClick={() => setLastAction("archive")}
type="button"
variant="outline"
>
Archive
</Button>
</div>
<p className="text-muted-foreground text-xs">
Last action:{" "}
<span className="font-medium text-foreground">{lastAction}</span>
</p>
<TourFrame tour={tour} />
</div>
);
};
export default TourWaitForClickDemo;
Wait For Input
0 of 5
import { RotateCcwIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
useTour,
waitForEvent,
} from "@/components/ui/tour";
const TourWaitForInputDemo = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [terms, setTerms] = useState(false);
const resetForm = () => {
setName("");
setEmail("");
setTerms(false);
};
const tour = useTour({
steps: [
{
id: "intro",
type: "dialog",
title: "Form tutorial",
description:
"Each step waits until the current field contains valid input.",
actions: [{ label: "Start", action: "next" }],
},
{
id: "name",
type: "tooltip",
arrow: true,
title: "Enter your name",
description: "Type at least two characters to continue.",
target: () => document.querySelector("#tour-form-name"),
effect: ({ next, show }) => {
show();
const input =
document.querySelector<HTMLInputElement>("#tour-form-name");
if (!input) return;
const [promise, cancel] = waitForEvent<HTMLInputElement>(
() => input,
"input",
{
predicate: (el) => el.value.trim().length >= 2,
},
);
promise.then(() => next());
return cancel;
},
},
{
id: "email",
type: "tooltip",
arrow: true,
title: "Enter your email",
description: "A valid email address unlocks the next step.",
target: () => document.querySelector("#tour-form-email"),
effect: ({ next, show }) => {
show();
const input =
document.querySelector<HTMLInputElement>("#tour-form-email");
if (!input) return;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const [promise, cancel] = waitForEvent<HTMLInputElement>(
() => input,
"input",
{
predicate: (el) => emailRegex.test(el.value),
},
);
promise.then(() => next());
return cancel;
},
},
{
id: "terms",
type: "tooltip",
arrow: true,
title: "Accept the terms",
description: "Check the box to complete the tutorial.",
target: () => document.querySelector("#tour-form-terms"),
effect: ({ next, show }) => {
show();
const input =
document.querySelector<HTMLInputElement>("#tour-form-terms");
if (!input) return;
const [promise, cancel] = waitForEvent<HTMLInputElement>(
() => input,
"change",
{
predicate: (el) => el.checked,
},
);
promise.then(() => next());
return cancel;
},
},
{
id: "done",
type: "dialog",
title: "Form complete",
description: "The tour advanced only after valid input was present.",
actions: [{ label: "Done", action: "dismiss" }],
},
],
});
return (
<div className={tourStageClass}>
<div className="flex flex-wrap gap-2">
<TourLaunchButton
label="Start form tour"
onClick={() => {
resetForm();
tour.start();
}}
/>
<Button onClick={resetForm} type="button" variant="ghost">
<RotateCcwIcon className="size-4" />
Reset
</Button>
</div>
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/15 p-4">
<Field>
<FieldLabel htmlFor="tour-form-name">Name</FieldLabel>
<Input
id="tour-form-name"
onChange={(event) => setName(event.target.value)}
placeholder="Ada Lovelace"
value={name}
/>
</Field>
<Field>
<FieldLabel htmlFor="tour-form-email">Email</FieldLabel>
<Input
id="tour-form-email"
onChange={(event) => setEmail(event.target.value)}
placeholder="ada@example.com"
type="email"
value={email}
/>
</Field>
<label className="inline-flex items-center gap-2 text-sm text-foreground">
<input
checked={terms}
className="size-4 accent-primary"
id="tour-form-terms"
onChange={(event) => setTerms(event.target.checked)}
type="checkbox"
/>
I accept the terms and conditions
</label>
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourWaitForInputDemo;
Wait For Element
Inbox
Drafts
0 of 3
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
TourFrame,
TourLaunchButton,
tourStageClass,
useTour,
waitForElement,
waitForEvent,
} from "@/components/ui/tour";
const baseItems = ["Inbox", "Drafts"];
const TourWaitForElementDemo = () => {
const [items, setItems] = useState(baseItems);
const resetItems = () => setItems(baseItems);
const tour = useTour({
steps: [
{
id: "intro",
type: "dialog",
title: "Dynamic content",
description:
"This example waits until a brand-new element is rendered on the page.",
actions: [{ label: "Start", action: "next" }],
},
{
id: "add-item",
type: "tooltip",
arrow: true,
title: "Add a new item",
description: "Click the button to create a fresh list row.",
target: () => document.querySelector("#tour-element-add"),
effect: ({ next, target, show }) => {
show();
const [promise, cancel] = waitForEvent(target, "click");
promise.then(() => next());
return cancel;
},
},
{
id: "new-item",
type: "tooltip",
arrow: true,
title: "Found it",
description:
"The tour waited until the new row existed before opening.",
target: () => document.querySelector("[data-tour-item='new']"),
effect: ({ show }) => {
const [promise, cancel] = waitForElement(
() => document.querySelector("[data-tour-item='new']"),
{ timeout: 5000 },
);
promise.then(() => show());
return () => cancel();
},
actions: [{ label: "Done", action: "dismiss" }],
},
],
});
return (
<div className={tourStageClass}>
<div className="flex flex-wrap gap-2">
<TourLaunchButton
onClick={() => {
resetItems();
tour.start();
}}
/>
<Button
id="tour-element-add"
onClick={() =>
setItems((current) => [...current, `Item ${current.length + 1}`])
}
type="button"
variant="outline"
>
Add item
</Button>
</div>
<div className="space-y-2">
{items.map((item, index) => (
<div
className="rounded-xl border border-border/70 bg-muted/20 px-4 py-3 text-sm text-foreground"
data-tour-item={
index === items.length - 1 && items.length > 2 ? "new" : undefined
}
key={item}
>
{item}
</div>
))}
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourWaitForElementDemo;
Async
Profile summary card
0 of 2
import {
TourFrame,
TourLaunchButton,
tourStageClass,
tourTargetClass,
useTour,
} from "@/components/ui/tour";
import { cn } from "@/lib/utils";
const TourAsyncDemo = () => {
const tour = useTour({
steps: [
{
id: "intro",
type: "dialog",
title: "Async loading",
description:
"This step waits for async data, then updates the copy before showing.",
actions: [{ label: "Continue", action: "next" }],
},
{
id: "profile",
type: "tooltip",
arrow: true,
title: "Loading profile...",
description: "Pulling usage metrics from the workspace service.",
target: () => document.querySelector("#tour-async-card"),
actions: [
{ label: "Back", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
effect: ({ show, update }) => {
const timeout = window.setTimeout(() => {
update({
title: "Workspace owner",
description:
"Ada shipped 12 docs, 4 demos, and 3 shared templates this week.",
});
show();
}, 900);
return () => window.clearTimeout(timeout);
},
},
],
});
return (
<div className={tourStageClass}>
<TourLaunchButton label="Start async tour" onClick={() => tour.start()} />
<div className={cn(tourTargetClass, "max-w-sm")} id="tour-async-card">
Profile summary card
</div>
<TourFrame tour={tour} />
</div>
);
};
export default TourAsyncDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
TourPopup
| Prop | Type | Description |
|---|---|---|
| arrowClassName? | string | Class on the arrow element. |
| arrowTipClassName? | string | Class on the arrow tip. |
| disablePortal? | boolean | Renders content in-place instead of portaled. |
| positionerClassName? | string | Extra class on the positioner wrapper. |
| showArrow? | boolean | Toggles the arrow (default true). |
TourFrame
| Prop | Type | Description |
|---|---|---|
| showProgressBar? | boolean | Renders a bottom progress bar. |
See the ARK UI documentation for the full API.