A SaaS app sets up a project in three clicks, one chapter each, with `chapterStrip` showing Create, Invite and Ship underneath. Click a chapter to jump there, or pause from the strip. On a phone, tap the app to take over and press Resume to hand it back.
@keyframeui
registry/demos/autopilot-onboarding-demo.tsx
"use client";
import * as React from "react";
import { Autopilot, type AutopilotChapter } from "@/components/ui/autopilot";
const templates = [
{ id: "website", name: "Website", note: "Marketing site with a blog", tint: "from-sky-400 to-indigo-500" },
{ id: "docs", name: "Docs", note: "Searchable product docs", tint: "from-amber-300 to-orange-500" },
{ id: "store", name: "Store", note: "Catalog and checkout", tint: "from-emerald-300 to-teal-600" },
];
const teammates = [
{ name: "Sam Okafor", tint: "bg-teal-500" },
{ name: "Priya Raman", tint: "bg-rose-500" },
{ name: "Leo Park", tint: "bg-sky-500" },
];
type Stage = "empty" | "created" | "invited" | "building" | "live";
const order: Stage[] = ["empty", "created", "invited", "building", "live"];
const reached = (stage: Stage, step: Stage) => order.indexOf(stage) >= order.indexOf(step);
export function AutopilotOnboardingDemo() {
const [stage, setStage] = React.useState<Stage>("empty");
const [template, setTemplate] = React.useState("website");
// Publishing takes a moment, as a real build would.
React.useEffect(() => {
if (stage !== "building") return;
const timer = window.setTimeout(() => setStage("live"), 1100);
return () => window.clearTimeout(timer);
}, [stage]);
const chapters: AutopilotChapter[] = [
{
title: "Create",
setup: () => setStage("empty"),
steps: [
{ move: "template-website", say: "1 · Pick a template" },
{ click: "template-website" },
{ wait: 900, say: "Project ready" },
],
},
{
title: "Invite",
setup: () => {
setTemplate("website");
setStage("created");
},
steps: [
{ move: "invite", say: "2 · Bring your team" },
{ click: "invite" },
{ wait: 1000, say: "Three teammates in" },
],
},
{
title: "Ship",
setup: () => {
setTemplate("website");
setStage("invited");
},
steps: [
{ move: "publish", say: "3 · Ship it" },
{ click: "publish" },
{ wait: 1800, say: "Live in one click" },
],
},
];
const picked = templates.find((item) => item.id === template) ?? templates[0];
return (
<Autopilot
cursors={[{ id: "guide", label: "Guide", color: "#0f766e" }]}
chapters={chapters}
chapterStrip
className="w-full max-w-3xl text-zinc-900 dark:text-zinc-100"
stageClassName="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-white/10 dark:bg-zinc-900"
>
<div className="flex h-[24rem]">
{/* Sidebar */}
<aside className="hidden w-44 shrink-0 flex-col border-r border-zinc-200 bg-zinc-50 p-3 sm:flex dark:border-white/10 dark:bg-white/[0.02]">
<div className="flex items-center gap-2 px-1.5 py-1">
<span className="grid size-6 place-items-center rounded-md bg-teal-700 text-[11px] font-bold text-white">N</span>
<span className="text-sm font-semibold">Northwind</span>
</div>
<nav className="mt-4 flex flex-col gap-0.5 text-[13px]">
{["Projects", "Deployments", "Team", "Settings"].map((item, i) => (
<span
key={item}
className={`rounded-md px-2 py-1.5 ${i === 0 ? "bg-white font-medium shadow-sm ring-1 ring-zinc-200 dark:bg-white/10 dark:ring-white/10" : "text-zinc-500 dark:text-zinc-400"}`}
>
{item}
</span>
))}
</nav>
<div className="mt-auto flex items-center gap-2 px-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="grid size-6 place-items-center rounded-full bg-zinc-800 text-[10px] font-semibold text-white dark:bg-zinc-600">
A
</span>
Ana · Acme
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-200 px-4 dark:border-white/10">
<p className="truncate text-sm">
<span className="text-zinc-400">Projects</span>
{stage !== "empty" && <span className="font-medium"> / {picked.name}</span>}
</p>
{stage !== "empty" && (
<span
className={`ml-1 inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ${
stage === "live"
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"
: "bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-300"
}`}
>
<span
className={`size-1.5 rounded-full ${stage === "live" ? "bg-emerald-500" : stage === "building" ? "animate-pulse bg-amber-500" : "bg-zinc-400"}`}
/>
{stage === "live" ? "Live" : stage === "building" ? "Building" : "Draft"}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{reached(stage, "invited") && (
<div className="flex -space-x-1.5">
{teammates.map((person) => (
<span
key={person.name}
title={person.name}
className={`grid size-6 place-items-center rounded-full text-[9px] font-semibold text-white ring-2 ring-white dark:ring-zinc-900 ${person.tint}`}
>
{initials(person.name)}
</span>
))}
</div>
)}
{stage !== "empty" && (
<button
type="button"
data-autopilot="publish"
disabled={stage === "building"}
onClick={() => setStage(stage === "live" ? "invited" : "building")}
className="inline-flex h-8 items-center rounded-lg bg-zinc-900 px-3 text-xs font-medium text-white transition-[background-color,scale] duration-150 hover:bg-zinc-700 active:scale-[0.97] disabled:opacity-60 data-[autopilot-hover]:bg-zinc-700 data-[autopilot-press]:scale-[0.97] dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:data-[autopilot-hover]:bg-zinc-200"
>
{stage === "live" ? "Unpublish" : stage === "building" ? "Publishing…" : "Publish"}
</button>
)}
</div>
</header>
{stage === "empty" ? (
<div className="flex flex-1 flex-col justify-center px-4 py-6 sm:px-8">
<p className="text-lg font-semibold tracking-tight">Start a project</p>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Pick a template. You can change everything later.</p>
<div className="mt-5 grid grid-cols-3 gap-2.5 sm:gap-3">
{templates.map((item) => (
<button
key={item.id}
type="button"
data-autopilot={`template-${item.id}`}
onClick={() => {
setTemplate(item.id);
setStage("created");
}}
className="group/template rounded-xl border border-zinc-200 p-2 text-left transition-[border-color,box-shadow,scale] duration-150 hover:border-zinc-300 hover:shadow-md active:scale-[0.98] data-[autopilot-hover]:border-zinc-300 data-[autopilot-hover]:shadow-md data-[autopilot-press]:scale-[0.98] dark:border-white/10 dark:hover:border-white/20 dark:data-[autopilot-hover]:border-white/20"
>
<span className={`block aspect-[4/3] rounded-lg bg-gradient-to-br p-2 ${item.tint}`}>
<span className="block h-1.5 w-1/2 rounded-sm bg-white/80" />
<span className="mt-1 block h-1 w-1/3 rounded-sm bg-white/50" />
</span>
<span className="mt-2 block text-[13px] font-medium">{item.name}</span>
<span className="hidden text-[11px] text-zinc-500 sm:block dark:text-zinc-400">{item.note}</span>
</button>
))}
</div>
</div>
) : (
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-4 sm:grid-cols-[1fr_13rem]">
{/* Preview */}
<div className="hidden flex-col overflow-hidden rounded-xl border border-zinc-200 sm:flex dark:border-white/10">
<div className="flex h-7 items-center gap-1.5 border-b border-zinc-200 px-2.5 dark:border-white/10">
<span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span className="ml-2 truncate font-mono text-[10px] text-zinc-400">
{stage === "live" ? "website-7f3.northwind.app" : "preview"}
</span>
</div>
<div className={`flex-1 bg-gradient-to-br p-4 ${picked.tint}`}>
<span className="block h-2.5 w-2/3 rounded-sm bg-white/90" />
<span className="mt-1.5 block h-2.5 w-1/2 rounded-sm bg-white/90" />
<span className="mt-3 block h-1.5 w-3/4 rounded-sm bg-white/60" />
<span className="mt-1 block h-1.5 w-2/3 rounded-sm bg-white/60" />
<span className="mt-4 block h-5 w-16 rounded-full bg-white" />
</div>
</div>
{/* Checklist */}
<div className="flex flex-col gap-2">
<p className="text-xs font-medium tracking-wide text-zinc-400 uppercase">Getting started</p>
<Task done label={`Created from ${picked.name}`} />
<Task done={reached(stage, "invited")} label={reached(stage, "invited") ? "Team invited" : "Invite your team"}>
{!reached(stage, "invited") && (
<div className="mt-2 flex items-center gap-2">
<div className="flex -space-x-1.5">
{teammates.map((person) => (
<span
key={person.name}
className={`grid size-5 place-items-center rounded-full text-[8px] font-semibold text-white ring-2 ring-white dark:ring-zinc-900 ${person.tint}`}
>
{initials(person.name)}
</span>
))}
</div>
<button
type="button"
data-autopilot="invite"
onClick={() => setStage("invited")}
className="ml-auto h-7 rounded-md border border-zinc-200 px-2.5 text-xs font-medium transition-[background-color,scale] duration-150 hover:bg-zinc-50 active:scale-[0.97] data-[autopilot-hover]:bg-zinc-50 data-[autopilot-press]:scale-[0.97] dark:border-white/15 dark:hover:bg-white/5 dark:data-[autopilot-hover]:bg-white/5"
>
Invite 3
</button>
</div>
)}
</Task>
<Task
done={stage === "live"}
label={stage === "live" ? "Published" : stage === "building" ? "Publishing…" : "Publish"}
>
{stage === "live" && (
<p className="mt-1 truncate font-mono text-[10px] text-emerald-600 dark:text-emerald-400">
website-7f3.northwind.app
</p>
)}
</Task>
</div>
</div>
)}
</main>
</div>
</Autopilot>
);
}
function Task({ done, label, children }: { done: boolean; label: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 p-2.5 text-[13px] dark:border-white/10">
<p className="flex items-center gap-2">
<span
className={`grid size-4 shrink-0 place-items-center rounded-full text-[9px] transition-colors duration-300 ${
done ? "bg-emerald-500 text-white" : "border border-dashed border-zinc-300 dark:border-zinc-600"
}`}
>
{done && "✓"}
</span>
<span className={done ? "text-zinc-500 dark:text-zinc-400" : "font-medium"}>{label}</span>
</p>
{children}
</div>
);
}
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.join("");
Live preview, examples and props: https://keyframeui.com/components/autopilot#autopilot-onboarding-demo














