An AI agent books a meeting in a calendar built from plain markup. It opens a new event, names it, finds a free slot, picks a guest from the autocomplete and sends the invite, narrating each step in its label. Move your mouse in or tab into it and it steps aside to the corner set with `park`; leave, and two seconds later it replays the chapter it was in.
@keyframeui
registry/demos/autopilot-demo.tsx
"use client";
import * as React from "react";
import { Autopilot, type AutopilotChapter } from "@/components/ui/autopilot";
const days = [
{ key: "mon", label: "Mon", date: 13, wide: true },
{ key: "tue", label: "Tue", date: 14 },
{ key: "wed", label: "Wed", date: 15 },
{ key: "thu", label: "Thu", date: 16 },
{ key: "fri", label: "Fri", date: 17, wide: true },
];
const hours = [9, 10, 11, 12, 13, 14, 15, 16];
const ROW = 40; // px per hour
const tones = {
sky: "border-sky-500 bg-sky-50 text-sky-900 dark:bg-sky-400/15 dark:text-sky-100",
teal: "border-teal-500 bg-teal-50 text-teal-900 dark:bg-teal-400/15 dark:text-teal-100",
amber: "border-amber-500 bg-amber-50 text-amber-900 dark:bg-amber-400/15 dark:text-amber-100",
rose: "border-rose-500 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100",
slate: "border-zinc-400 bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-200",
};
const events: { id: string; day: string; start: number; end: number; title: string; tone: keyof typeof tones }[] = [
{ id: "mon-standup", day: "mon", start: 9, end: 9.5, title: "Standup", tone: "sky" },
{ id: "mon-1on1", day: "mon", start: 10.5, end: 11.5, title: "1:1 with Sam", tone: "teal" },
{ id: "mon-focus", day: "mon", start: 14, end: 16, title: "Focus time", tone: "slate" },
{ id: "tue-standup", day: "tue", start: 9, end: 9.5, title: "Standup", tone: "sky" },
{ id: "tue-roadmap", day: "tue", start: 11, end: 12.5, title: "Roadmap sync", tone: "rose" },
{ id: "calls", day: "tue", start: 13, end: 15, title: "Customer calls", tone: "amber" },
{ id: "wed-standup", day: "wed", start: 9, end: 9.5, title: "Standup", tone: "sky" },
{ id: "wed-lunch", day: "wed", start: 12, end: 13, title: "Lunch & learn", tone: "teal" },
{ id: "wed-review", day: "wed", start: 15, end: 17, title: "Sprint review", tone: "rose" },
{ id: "thu-panel", day: "thu", start: 10, end: 11.5, title: "Hiring panel", tone: "amber" },
{ id: "thu-crit", day: "thu", start: 13, end: 14, title: "Design crit", tone: "slate" },
{ id: "fri-standup", day: "fri", start: 9, end: 9.5, title: "Standup", tone: "sky" },
{ id: "fri-retro", day: "fri", start: 11, end: 12, title: "Retro", tone: "teal" },
];
const people = [
{ id: "priya", name: "Priya Raman", email: "priya@northwind.co", tint: "bg-rose-500" },
{ id: "pablo", name: "Pablo Ortiz", email: "pablo@northwind.co", tint: "bg-sky-500" },
{ id: "maya", name: "Maya Chen", email: "maya@northwind.co", tint: "bg-amber-500" },
{ id: "sam", name: "Sam Okafor", email: "sam@northwind.co", tint: "bg-teal-500" },
];
const isFree = (day: string, hour: number) =>
!events.some((event) => event.day === day && event.start < hour + 1 && event.end > hour);
const clock = (hour: number) => `${((hour + 11) % 12) + 1}${hour < 12 ? " AM" : " PM"}`;
const hm = (hour: number) => {
const h = ((Math.floor(hour) + 11) % 12) + 1;
return hour % 1 ? `${h}:${String(Math.round((hour % 1) * 60)).padStart(2, "0")}` : `${h}`;
};
const range = (start: number, end: number) => `${hm(start)} – ${hm(end)}${end < 12 ? "am" : "pm"}`;
type Booking = {
open: boolean;
title: string;
slot: { day: string; hour: number } | null;
query: string;
guests: string[];
sent: boolean;
};
const blank: Booking = { open: false, title: "", slot: null, query: "", guests: [], sent: false };
const named: Booking = { ...blank, open: true, title: "Design review" };
export function AutopilotDemo() {
const [booking, setBooking] = React.useState(blank);
const [toast, setToast] = React.useState(false);
const update = (patch: Partial<Booking>) => setBooking((current) => ({ ...current, ...patch }));
React.useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(false), 4000);
return () => window.clearTimeout(timer);
}, [toast]);
const chapters: AutopilotChapter[] = [
{
title: "Draft",
setup: () => {
setBooking(blank);
setToast(false);
},
steps: [
{ move: "new-event", say: "Booking a design review" },
{ click: "new-event" },
{ wait: 350 },
{ type: "Design review", into: "title", say: "Giving it a name" },
],
},
{
title: "Schedule",
setup: () => {
setBooking(named);
setToast(false);
},
steps: [
{ move: "calls", at: [0.55, 0.7], say: "Finding a free slot…" },
{ wait: 450 },
{ click: "slot-tue-15", say: "Tuesday at 3 is free" },
{ wait: 300 },
],
},
{
title: "Invite",
setup: () => {
setBooking({ ...named, slot: { day: "tue", hour: 15 } });
setToast(false);
},
steps: [
{ type: "pri", into: "guests", say: "Adding Priya" },
{ click: "guest-priya" },
{ click: "send", say: "Sending the invite" },
{ wait: 1400, say: "Booked for Tuesday at 3" },
],
},
];
const matches = booking.query
? people.filter(
(person) =>
!booking.guests.includes(person.id) &&
person.name.toLowerCase().includes(booking.query.toLowerCase()),
)
: [];
const slotDay = days.find((day) => day.key === booking.slot?.day);
const canSend = booking.title.trim() !== "" && booking.slot !== null;
return (
<Autopilot
cursors={[{ id: "agent", label: "Agent", color: "#6d5efc" }]}
chapters={chapters}
park="bottom-left"
className="w-full max-w-3xl"
stageClassName="overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 shadow-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100"
>
<div className="relative flex h-[28rem] flex-col">
{/* Toolbar */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-zinc-200 px-3 sm:px-4 dark:border-white/10">
<div className="flex items-center gap-2">
<span className="grid size-6 place-items-center rounded-md bg-zinc-900 text-[10px] font-semibold text-white dark:bg-white dark:text-zinc-900">
14
</span>
<span className="text-sm font-semibold">Calendar</span>
</div>
<div className="ml-2 hidden items-center gap-1 text-sm text-zinc-500 sm:flex dark:text-zinc-400">
<span className="grid size-6 place-items-center rounded-md">‹</span>
<span className="font-medium text-zinc-800 dark:text-zinc-200">Oct 13 – 17, 2026</span>
<span className="grid size-6 place-items-center rounded-md">›</span>
</div>
<button
type="button"
data-autopilot="new-event"
onClick={() => update({ open: true, sent: false })}
className="ml-auto inline-flex h-8 items-center gap-1.5 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] 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"
>
<span aria-hidden className="text-sm leading-none">
+
</span>
New event
</button>
</div>
{/* Week */}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-10 shrink-0 border-b border-zinc-200 dark:border-white/10">
<div className="w-11 shrink-0 sm:w-14" />
{days.map((day) => (
<div
key={day.key}
className={`flex-1 items-baseline gap-1.5 border-l border-zinc-100 px-2 pt-2.5 text-xs dark:border-white/5 ${day.wide ? "hidden sm:flex" : "flex"}`}
>
<span className="text-zinc-500 dark:text-zinc-400">{day.label}</span>
<span
className={
day.key === "mon"
? "grid size-5 place-items-center rounded-full bg-rose-500 text-[11px] font-semibold text-white"
: "font-semibold"
}
>
{day.date}
</span>
</div>
))}
</div>
<div className="relative flex flex-1 overflow-hidden">
<div className="w-11 shrink-0 sm:w-14">
{hours.map((hour) => (
<div
key={hour}
style={{ height: ROW }}
className="pr-2 text-right text-[10px] text-zinc-400 tabular-nums dark:text-zinc-500"
>
<span className="relative -top-1.5">{hour === 9 ? "" : clock(hour)}</span>
</div>
))}
</div>
{days.map((day) => (
<div
key={day.key}
className={`relative flex-1 border-l border-zinc-100 dark:border-white/5 ${day.wide ? "hidden sm:block" : "block"}`}
>
{hours.map((hour) => (
<div
key={hour}
style={{ top: (hour - 9) * ROW, height: ROW }}
className="absolute inset-x-0 border-t border-zinc-100 dark:border-white/5"
>
{isFree(day.key, hour) && (
<button
type="button"
data-autopilot={`slot-${day.key}-${hour}`}
aria-label={`${day.label} ${day.date}, ${clock(hour)}`}
onClick={() => update({ open: true, slot: { day: day.key, hour }, sent: false })}
className="group/slot absolute inset-0.5 rounded-md text-[10px] font-medium text-violet-600 transition-colors hover:bg-violet-50 data-[autopilot-hover]:bg-violet-50 dark:text-violet-300 dark:hover:bg-violet-400/10 dark:data-[autopilot-hover]:bg-violet-400/10"
>
<span className="opacity-0 transition-opacity group-hover/slot:opacity-100 group-data-[autopilot-hover]/slot:opacity-100">
+ {clock(hour)}
</span>
</button>
)}
</div>
))}
{day.key === "mon" && (
<div aria-hidden style={{ top: 1.35 * ROW }} className="absolute inset-x-0 z-[1] h-px bg-rose-500">
<span className="absolute -top-1 -left-1 size-2 rounded-full bg-rose-500" />
</div>
)}
{events
.filter((event) => event.day === day.key)
.map((event) => (
<div
key={event.id}
data-autopilot={event.id}
style={{ top: (event.start - 9) * ROW + 2, height: (event.end - event.start) * ROW - 4 }}
className={`absolute inset-x-1 overflow-hidden rounded-md border-l-2 px-2 text-[11px] leading-4 transition-shadow data-[autopilot-hover]:ring-2 data-[autopilot-hover]:ring-current/15 ${event.end - event.start < 1 ? "py-0" : "py-1"} ${tones[event.tone]}`}
>
<p className="truncate font-medium">{event.title}</p>
{event.end - event.start >= 1 && (
<p className="truncate opacity-70 tabular-nums">{range(event.start, event.end)}</p>
)}
</div>
))}
{booking.slot?.day === day.key && (
<div
style={{ top: (booking.slot.hour - 9) * ROW + 2, height: ROW * 1 - 4 }}
className={`absolute inset-x-1 overflow-hidden rounded-md px-2 py-1 text-[11px] leading-tight transition-colors duration-300 ${
booking.sent
? "bg-violet-600 text-white"
: "border border-dashed border-violet-400 bg-violet-50 text-violet-800 dark:bg-violet-400/15 dark:text-violet-100"
}`}
>
<p className="truncate font-medium">{booking.title || "(No title)"}</p>
</div>
)}
</div>
))}
</div>
</div>
{/* New event panel */}
<div
inert={!booking.open}
aria-hidden={!booking.open}
className={`absolute top-12 right-0 bottom-0 z-10 flex w-52 flex-col border-l border-zinc-200 bg-white shadow-[-12px_0_32px_-16px_rgb(0_0_0/0.18)] transition-transform duration-300 ease-out sm:w-72 dark:border-white/10 dark:bg-zinc-900 ${
booking.open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 pt-4">
<p className="text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
New event
</p>
<button
type="button"
aria-label="Close"
onClick={() => update({ open: false })}
className="grid size-6 place-items-center rounded-md text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-white/10 dark:hover:text-zinc-200"
>
×
</button>
</div>
<div className="flex flex-1 flex-col gap-4 px-4 pt-3 pb-4">
<input
data-autopilot="title"
value={booking.title}
onChange={(event) => update({ title: event.target.value })}
placeholder="Add title"
aria-label="Title"
className="h-10 w-full rounded-lg border border-zinc-200 bg-transparent px-3 text-base font-medium outline-none placeholder:text-zinc-400 focus:border-violet-500 focus:ring-3 focus:ring-violet-500/20 data-[autopilot-focus]:border-violet-500 data-[autopilot-focus]:ring-3 data-[autopilot-focus]:ring-violet-500/20 sm:text-sm dark:border-white/10"
/>
<div className="flex items-start gap-2.5 text-sm">
<ClockIcon />
{booking.slot && slotDay ? (
<p>
{slotDay.label}, Oct {slotDay.date}
<span className="block text-xs text-zinc-500 dark:text-zinc-400">
{clock(booking.slot.hour)} – {clock(booking.slot.hour + 1)}
</span>
</p>
) : (
<p className="text-zinc-400 dark:text-zinc-500">Pick a time on the calendar</p>
)}
</div>
<div className="flex items-start gap-2.5 text-sm">
<PeopleIcon />
<div className="relative min-w-0 flex-1">
{booking.guests.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{booking.guests.map((id) => {
const person = people.find((p) => p.id === id)!;
return (
<span
key={id}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 py-0.5 pr-2 pl-0.5 text-xs dark:bg-white/10"
>
<Avatar person={person} />
{person.name}
</span>
);
})}
</div>
)}
<input
data-autopilot="guests"
value={booking.query}
onChange={(event) => update({ query: event.target.value })}
placeholder="Add guests"
aria-label="Add guests"
className="h-8 w-full rounded-lg border border-zinc-200 bg-transparent px-2.5 text-base outline-none placeholder:text-zinc-400 focus:border-violet-500 focus:ring-3 focus:ring-violet-500/20 data-[autopilot-focus]:border-violet-500 data-[autopilot-focus]:ring-3 data-[autopilot-focus]:ring-violet-500/20 sm:text-sm dark:border-white/10"
/>
{matches.length > 0 && (
<ul className="absolute inset-x-0 top-full z-10 mt-1 overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 shadow-lg dark:border-white/10 dark:bg-zinc-800">
{matches.map((person) => (
<li key={person.id}>
<button
type="button"
data-autopilot={`guest-${person.id}`}
onClick={() => update({ guests: [...booking.guests, person.id], query: "" })}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-zinc-100 data-[autopilot-hover]:bg-zinc-100 dark:hover:bg-white/10 dark:data-[autopilot-hover]:bg-white/10"
>
<Avatar person={person} />
<span className="min-w-0">
<span className="block truncate text-xs font-medium">{person.name}</span>
<span className="block truncate text-[11px] text-zinc-500 dark:text-zinc-400">
{person.email}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</div>
<button
type="button"
data-autopilot="send"
disabled={!canSend}
onClick={() => {
update({ open: false, sent: true, query: "" });
setToast(true);
}}
className="mt-auto h-9 rounded-lg bg-violet-600 text-sm font-medium text-white transition-[background-color,scale,opacity] duration-150 hover:bg-violet-500 active:scale-[0.98] disabled:opacity-40 data-[autopilot-hover]:bg-violet-500 data-[autopilot-press]:scale-[0.98]"
>
Send invite
</button>
</div>
</div>
{/* Toast */}
<div
role="status"
className={`pointer-events-none absolute top-14 left-1/2 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-zinc-900 py-1.5 pr-3.5 pl-2 text-xs whitespace-nowrap text-white shadow-lg transition-[opacity,translate] duration-300 dark:bg-white dark:text-zinc-900 ${
toast ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`}
>
{toast && (
<>
<span className="grid size-4 place-items-center rounded-full bg-emerald-500 text-[10px] text-white">✓</span>
Invite sent to {booking.guests.length ? people.find((p) => p.id === booking.guests[0])?.name : "your guests"}
</>
)}
</div>
</div>
</Autopilot>
);
}
function Avatar({ person }: { person: (typeof people)[number] }) {
return (
<span
aria-hidden
className={`grid size-5 shrink-0 place-items-center rounded-full text-[9px] font-semibold text-white ${person.tint}`}
>
{person.name
.split(" ")
.map((part) => part[0])
.join("")}
</span>
);
}
function ClockIcon() {
return (
<svg viewBox="0 0 16 16" className="mt-0.5 size-4 shrink-0 text-zinc-400" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 4.75V8l2.25 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function PeopleIcon() {
return (
<svg viewBox="0 0 16 16" className="mt-2 size-4 shrink-0 text-zinc-400" fill="none" aria-hidden>
<circle cx="6" cy="5.5" r="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.75 13c.5-2.2 2.2-3.5 4.25-3.5S9.75 10.8 10.25 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M10.5 3.2a2.4 2.4 0 0 1 0 4.6M12 9.7c1.2.5 2 1.6 2.25 3.3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
Live preview, examples and props: https://keyframeui.com/components/autopilot#autopilot-demo














