Autopilot
A cursor that uses your product on its own. It glides to buttons and presses them, types and drags across real markup on a script you write, and steps aside the moment a visitor wants to try it.
- Cursor
- Interaction
- Showcase
Uses motion, clsx, tailwind-merge
Standup
Roadmap sync
11 – 12:30pm
Customer calls
1 – 3pm
Standup
Lunch & learn
12 – 1pm
Sprint review
3 – 5pm
Hiring panel
10 – 11:30am
Design crit
1 – 2pm
"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>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/autopilot.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/autopilotInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/autopilot.tsx"use client"; import * as React from "react"; import { AnimatePresence, animate, motion, motionValue, useMotionValue, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * AUTOPILOT STORYBOARD * * rest your UI, untouched, with a cursor (or a few) at home. * Each trails a pill in its own color: a name, or what * it's doing right now * move it glides to the next target on a gentle arc, easing * in and out: about 350ms for a hop, 900ms across the * stage. Long trips overshoot a touch and correct. * Targets it passes over get data-autopilot-hover * click a beat over the target, then the pointer squashes, a * ring spreads from its tip and the target gets * data-autopilot-press. Pointer, mouse and click events * fire on the element, so the UI's own handlers run * type it clicks into the field, which shows data-autopilot- * focus instead of taking real focus, eases the pointer * off the line, and types at a person's uneven pace: a * pause after spaces and punctuation, the odd burst * drag the item lifts with a little scale and shadow, rides * the path, and settles into wherever the drop puts it. * The target shows data-autopilot-drop while it's over * say the old label slides up and out as the new one comes * in, and the pill's width follows * yield the visitor's pointer or focus comes in: each cursor * finishes the key or click it's on, glides to the * corner and fades, reading "Your turn". Two seconds * after they leave it glides back and replays the * chapter it was in, from its setup. On touch screens a * tap takes over and Resume hands it back * loop after the last chapter the finished state holds for * 2s, the stage crossfades back to the start and the * cursors glide home * pause off screen, in a background tab, or with the pause * button. The chapter strip fills like a story timer; * click a chapter to jump to it * reduced no autoplay: the first state with a Play button. When * played, cursors jump between targets, with no ripple, * overshoot or settle * * Cursors move on motion values, outside React. The script is * one async run that a takeover, a pause or a jump aborts; the * next run waits for it to finish the action it's in. * ───────────────────────────────────────────────────────── */ const AUTOPILOT = { hop: 350, // ms to move 40px trip: 900, // ms to move 800px. Moves in between follow Fitts's law quickest: 200, // ms, the shortest move bow: 0.12, // how far a path bows off the straight line, as a share of its length overshootFrom: 240, // px; longer moves overshoot and correct overshoot: 0.035, // share of the distance a long move overshoots by overshootMax: 14, // px correct: 150, // ms the correction takes beat: 140, // ms over a target before it presses press: 90, // ms a button is held down squash: 0.84, // pointer scale while pressed ripple: 0.45, // s the click ring takes to spread and fade key: 70, // ms between keys, on average space: 60, // ms more after a space stop: 200, // ms more after punctuation burst: 0.15, // chance the next word comes out in a quick burst lift: 1.04, // scale of a dragged item carry: 1.2, // a drag takes this much longer than a bare move settle: { type: "spring", stiffness: 520, damping: 30 }, yield: 320, // ms to glide aside when the visitor takes over resume: 2000, // ms after the visitor leaves before it carries on hold: 2000, // ms the finished state holds before the loop starts over fade: 0.3, // s the reset crossfade takes parked: 0.45, // the pointer's opacity while parked; its label stays readable find: 2000, // ms to wait for a target that isn't on screen yet label: { type: "spring", visualDuration: 0.3, bounce: 0 }, } as const; // The pill sits under the arrow's tail, kept inside the stage. const PILL = { x: 12, y: 20, height: 24, inset: 4 } as const; /* ── Script ─────────────────────────────────────────────── */ export type AutopilotCursor = { /** Steps name their cursor with this. */ id: string; /** The pill's text when the cursor isn't saying anything: a name, or "Agent". */ label: string; /** The pointer's and pill's color. The pill's text is white. */ color: string; /** Where it waits at the start and between loops, as fractions of the stage. Defaults to the corner it parks in. */ home?: [number, number]; }; type StepBase = { /** The cursor that takes this step. Defaults to the first one. */ cursor?: string; /** Changes the cursor's label as the step starts, and keeps it. `null` goes back to its name. */ say?: string | null; }; /** Where on the target to point, as fractions of its width and height. Defaults to near the middle. */ type Aim = { at?: [number, number] }; export type AutopilotStep = StepBase & ( | ({ move: string } & Aim) | ({ click: string } & Aim) | ({ type: string; into: string } & Aim) | ({ drag: string; to: string; drop?: () => void } & Aim) | { wait: number } | { do: () => void | Promise<void> } ); export type AutopilotChapter = { title: string; /** Puts the UI in the state this chapter starts from. Runs every time the chapter starts. */ setup?: () => void; steps: AutopilotStep[]; }; /* ── Motion helpers ─────────────────────────────────────── */ type Point = { x: number; y: number }; type Box = { left: number; top: number; right: number; bottom: number; width: number; height: number }; type Flag = "hover" | "press" | "focus" | "drag" | "drop"; type Field = HTMLInputElement | HTMLTextAreaElement; type Rig = { x: MotionValue<number>; y: MotionValue<number>; opacity: MotionValue<number>; rest: MotionValue<number>; // 0 → 1 as it parks squash: MotionValue<number>; ring: MotionValue<number>; // 0 → 1 as a click ring spreads /** Ends the glide in progress where it is. */ stop: () => void; hover: Element | null; focus: Element | null; /** Where it rests, so it can follow the stage when it resizes. */ placed: "home" | "park" | null; }; const noop = () => {}; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const vary = (value: number, spread: number) => value * (1 - spread + Math.random() * spread * 2); // Minimum jerk: the bell-shaped speed of a hand reaching for something. const reach = (t: number) => t * t * t * (10 - 15 * t + 6 * t * t); const EASE_OUT = [0.22, 1, 0.36, 1] as const; // Move time grows with the log of the distance, through `hop` at 40px and `trip` at 800px. const FITTS = (AUTOPILOT.trip - AUTOPILOT.hop) / Math.log2(41 / 3); const travel = (distance: number) => Math.max(AUTOPILOT.quickest, AUTOPILOT.hop + FITTS * Math.log2((1 + distance / 20) / 3)); const tip = (rig: Rig): Point => ({ x: rig.x.get(), y: rig.y.get() }); const inside = (box: Box, p: Point, inset = 2) => p.x >= box.left + inset && p.x <= box.right - inset && p.y >= box.top + inset && p.y <= box.bottom - inset; /** The control point of a path that bows toward the top left, the way a wrist swings, now and then the other way. */ function bend(from: Point, to: Point, bow: number): Point { const dx = to.x - from.x; const dy = to.y - from.y; const length = Math.hypot(dx, dy); const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; if (!length || !bow) return mid; let nx = -dy / length; let ny = dx / length; if (nx + ny > 0) { nx = -nx; ny = -ny; } const side = Math.random() < 0.2 ? -1 : 1; const amount = length * vary(bow, 0.35) * side * (length < 80 ? 0.5 : 1); return { x: mid.x + nx * amount, y: mid.y + ny * amount }; } function sleep(ms: number, signal?: AbortSignal) { return new Promise<void>((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const cancel = () => { window.clearTimeout(timer); reject(signal?.reason); }; const timer = window.setTimeout(() => { signal?.removeEventListener("abort", cancel); resolve(); }, ms); signal?.addEventListener("abort", cancel, { once: true }); }); } function nextFrame(signal?: AbortSignal) { return new Promise<void>((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const cancel = () => { cancelAnimationFrame(id); reject(signal?.reason); }; const id = requestAnimationFrame(() => { signal?.removeEventListener("abort", cancel); resolve(); }); signal?.addEventListener("abort", cancel, { once: true }); }); } /** * Moves a cursor along a quadratic curve. Resolves when it arrives, or when * another glide takes over; rejects if the signal aborts first. */ function glide( rig: Rig, to: Point, ms: number, { signal, bow = 0, ease = reach, onFrame, }: { signal?: AbortSignal; bow?: number; ease?: ((t: number) => number) | typeof EASE_OUT; onFrame?: (p: Point) => void; } = {}, ) { rig.stop(); const from = tip(rig); const via = bend(from, to, bow); return new Promise<void>((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const settle = () => { signal?.removeEventListener("abort", cancel); rig.stop = noop; }; const cancel = () => { controls.stop(); settle(); reject(signal?.reason); }; const done = () => { settle(); resolve(); }; const controls = animate(0, 1, { duration: Math.max(ms, 1) / 1000, ease, onUpdate: (t) => { const u = 1 - t; const p = { x: u * u * from.x + 2 * u * t * via.x + t * t * to.x, y: u * u * from.y + 2 * u * t * via.y + t * t * to.y, }; rig.x.set(p.x); rig.y.set(p.y); onFrame?.(p); }, onComplete: done, }); rig.stop = () => { controls.stop(); done(); }; signal?.addEventListener("abort", cancel, { once: true }); }); } /** Mouse and pointer events with real coordinates, so handlers that read them work. */ function fire(target: Element, type: string, at: Point) { const down = type.endsWith("down"); const init = { bubbles: true, cancelable: true, composed: true, view: window, clientX: at.x, clientY: at.y, screenX: at.x + window.screenX, screenY: at.y + window.screenY, button: 0, buttons: down ? 1 : 0, detail: 1, }; target.dispatchEvent( type.startsWith("pointer") ? new PointerEvent(type, { ...init, pointerId: 41, // not a real pointer, so it never collides with the visitor's pointerType: "mouse", isPrimary: true, width: 1, height: 1, pressure: down ? 0.5 : 0, }) : new MouseEvent(type, init), ); } const fieldIn = (element: Element): Field | null => element.matches("input, textarea") ? (element as Field) : element.querySelector<Field>("input, textarea"); // React tracks an input's last value; setting it through the prototype lets onChange see the change. function setValue(field: Field, value: string, inputType: string, data: string | null) { const proto = field instanceof HTMLTextAreaElement ? HTMLTextAreaElement : HTMLInputElement; Object.getOwnPropertyDescriptor(proto.prototype, "value")?.set?.call(field, value); field.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data })); } function press(field: Field, key: string) { const init = { key, bubbles: true, cancelable: true, composed: true }; if (field.dispatchEvent(new KeyboardEvent("keydown", init))) { if (key === "Backspace") setValue(field, field.value.slice(0, -1), "deleteContentBackward", null); else if (key !== "Enter") setValue(field, field.value + key, "insertText", key); } field.dispatchEvent(new KeyboardEvent("keyup", init)); } const KEYS = /\{(?:Enter|Backspace)\}|[\s\S]/gu; const keysOf = (text: string) => (text.match(KEYS) ?? []).map((key) => key.replace(/^\{(.+)\}$/, "$1")); /** Holds a dragged item up: lifted on its own layer, out of the way of hit tests. */ function lift(element: HTMLElement) { const props = ["translate", "scale", "box-shadow", "z-index", "position", "pointer-events", "transition"]; const saved = props.map((prop) => [prop, element.style.getPropertyValue(prop)] as const); const computed = getComputedStyle(element); const own = computed.boxShadow === "none" ? "" : `, ${computed.boxShadow}`; element.style.transition = "none"; element.style.pointerEvents = "none"; element.style.zIndex = "50"; if (computed.position === "static") element.style.position = "relative"; let offset: Point = { x: 0, y: 0 }; let raised = 0; const paint = () => { const p = Math.max(raised, 0); element.style.translate = `${offset.x}px ${offset.y}px`; element.style.scale = `${1 + (AUTOPILOT.lift - 1) * raised}`; element.style.boxShadow = `0 ${16 * p}px ${32 * p}px -12px rgb(0 0 0 / ${0.3 * p}), 0 ${5 * p}px ${12 * p}px -6px rgb(0 0 0 / ${0.16 * p})${own}`; }; return { element, move(x: number, y: number) { offset = { x, y }; paint(); }, raise(p: number) { raised = p; paint(); }, restore() { for (const [prop, value] of saved) { if (value) element.style.setProperty(prop, value); else element.style.removeProperty(prop); } }, }; } /** A still copy of the stage laid over it, to crossfade from while the live UI resets underneath. */ function snapshot(content: HTMLElement, host: HTMLElement) { const copy = content.cloneNode(true) as HTMLElement; // inputs keep their values for (const element of [copy, ...copy.querySelectorAll("[id]")]) element.removeAttribute("id"); copy.inert = true; Object.assign(copy.style, { position: "absolute", left: `${content.offsetLeft}px`, top: `${content.offsetTop}px`, width: `${content.offsetWidth}px`, height: `${content.offsetHeight}px`, margin: "0", }); host.append(copy); return copy; } const warn = (message: string) => console.warn(`Autopilot: ${message}`); /* ── The pilot ──────────────────────────────────────────── */ export type AutopilotCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left"; type PilotConfig = { cursors: AutopilotCursor[]; chapters: AutopilotChapter[]; reduced: boolean; loop: boolean; park: AutopilotCorner; /** Whether stopping now means the visitor took over, so the cursors step aside. */ yielding: boolean; onChapterChange?: (index: number) => void; }; /** How the pilot tells React what to show. */ type PilotUI = { onChapter: (index: number) => void; onSay: (id: string, text: string | null) => void; onQuiet: () => void; onParked: (parked: boolean) => void; onFinish: () => void; }; /** * Everything that moves lives here, outside React: the cursors' motion * values, the attributes on the targets, and the run through the script. */ function createPilot() { const rigs = new Map<string, Rig>(); const flags = new Map<Element, Map<Flag, number>>(); const progress = motionValue(0); const width = motionValue(0); const height = motionValue(0); const measured: number[] = []; // how long each chapter took last time, for the strip's timer let config: PilotConfig = { cursors: [], chapters: [], reduced: false, loop: true, park: "bottom-right", yielding: false, }; let ui: PilotUI = { onChapter: noop, onSay: noop, onQuiet: noop, onParked: noop, onFinish: noop }; let stage: HTMLElement | null = null; let content: HTMLElement | null = null; let host: HTMLElement | null = null; let chapter = 0; // where the next run starts let pending: number | null = null; // a chapter picked from the strip let ended = false; // the last chapter finished, so the next run starts the loop over let dirty = false; // the UI may have moved on from the chapter's setup let parked = false; let actingUntil = 0; let fill: { stop: () => void } | null = null; let queue = Promise.resolve(); const rig = (id: string) => { let found = rigs.get(id); if (!found) { found = { x: motionValue(0), y: motionValue(0), opacity: motionValue(0), rest: motionValue(0), squash: motionValue(1), ring: motionValue(1), stop: noop, hover: null, focus: null, placed: null, }; rigs.set(id, found); } return found; }; const crew = () => config.cursors.map((cursor) => rig(cursor.id)); /* Geometry, in the stage's own px. */ const frame = () => { if (!stage) return { left: 0, top: 0, k: 1 }; const r = stage.getBoundingClientRect(); const k = stage.offsetWidth ? r.width / stage.offsetWidth : 1; return { left: r.left + stage.clientLeft * k, top: r.top + stage.clientTop * k, k }; }; const toClient = (p: Point) => { const f = frame(); return { x: f.left + p.x * f.k, y: f.top + p.y * f.k }; }; const boxOf = (element: Element): Box => { const f = frame(); const r = element.getBoundingClientRect(); const left = (r.left - f.left) / f.k; const top = (r.top - f.top) / f.k; const w = r.width / f.k; const h = r.height / f.k; return { left, top, right: left + w, bottom: top + h, width: w, height: h }; }; // Parked cursors line up from the corner inward; the first one, with the label, sits innermost on the right // and outermost on the left, clear of the pause button. const spot = (index: number): Point => { const [edge, side] = config.park.split("-"); const x = side === "right" ? width.get() - 112 - index * 22 : 64 - index * 20; const y = edge === "top" ? 16 : height.get() - (side === "left" ? 64 : 52); return { x: clamp(x, 8, Math.max(8, width.get() - 8)), y: clamp(y, 8, Math.max(8, height.get() - 8)) }; }; const home = (index: number): Point => { const at = config.cursors[index]?.home; return at ? { x: width.get() * at[0], y: height.get() * at[1] } : spot(index); }; /** Points at a target the way a hand would: near the middle, never dead center. Fields get clicked toward their start. */ const aim = (element: Element, at?: [number, number]): Point => { const box = boxOf(element); const [fx, fy] = at ?? (element.matches("input, textarea") ? [0.22, 0.5] : [0.5, 0.52]); const jitter = at ? 0 : 1; const x = box.left + box.width * fx + jitter * (Math.random() - 0.5) * Math.min(box.width * 0.3, 12); const y = box.top + box.height * fy + jitter * (Math.random() - 0.5) * Math.min(box.height * 0.3, 6); return { x: clamp(x, box.left + 2, box.right - 2), y: clamp(y, box.top + 2, box.bottom - 2) }; }; /* Attributes on targets, counted so cursors sharing a target don't clear each other. */ const flag = (element: Element | null, name: Flag, on: boolean) => { if (!element) return; const counts = flags.get(element) ?? new Map<Flag, number>(); const n = (counts.get(name) ?? 0) + (on ? 1 : -1); if (n > 0) counts.set(name, n); else counts.delete(name); if (counts.size) flags.set(element, counts); else flags.delete(element); element.toggleAttribute(`data-autopilot-${name}`, n > 0); }; const unflagAll = () => { for (const [element, counts] of flags) { for (const name of counts.keys()) element.removeAttribute(`data-autopilot-${name}`); } flags.clear(); for (const r of rigs.values()) { r.hover = null; r.focus = null; } }; const hoverOn = (r: Rig, element: Element | null) => { if (r.hover === element) return; flag(r.hover, "hover", false); r.hover = element; flag(element, "hover", true); }; const focusOn = (r: Rig, element: Element | null) => { if (r.focus === element) return; flag(r.focus, "focus", false); r.focus = element; flag(element, "focus", true); }; /** The UI under a cursor's tip. The cursors and anything dragged don't take hits. */ const hit = (p: Point) => { if (!content) return null; const c = toClient(p); const element = document.elementFromPoint(c.x, c.y); return element && content.contains(element) ? element : null; }; const hoverAt = (r: Rig, p: Point) => { const target = hit(p)?.closest("[data-autopilot]") ?? null; hoverOn(r, target && content?.contains(target) ? target : null); }; // A click can make the UI move focus itself, like a menu focusing its first item. That isn't the visitor. const acted = () => { actingUntil = performance.now() + 120; }; const find = async (name: string, signal: AbortSignal) => { const until = performance.now() + AUTOPILOT.find; for (;;) { const element = content?.querySelector(`[data-autopilot="${CSS.escape(name)}"]`); if (element && element.getClientRects().length) return element; if (performance.now() > until) { warn(`nothing marked data-autopilot="${name}" showed up.`); return null; } await nextFrame(signal); } }; /* Moves. */ const moveTo = async ( r: Rig, to: Point, signal: AbortSignal, { carry = false, onFrame }: { carry?: boolean; onFrame?: (p: Point) => void } = {}, ) => { r.placed = null; const step = (p: Point) => { hoverAt(r, p); onFrame?.(p); }; if (config.reduced) { r.stop(); r.x.jump(to.x); r.y.jump(to.y); step(to); await sleep(AUTOPILOT.beat * 2, signal); return; } const from = tip(r); const distance = Math.hypot(to.x - from.x, to.y - from.y); if (distance < 1) return; const ms = vary(travel(distance), 0.08) * (carry ? AUTOPILOT.carry : 1); if (carry || distance < AUTOPILOT.overshootFrom) { await glide(r, to, ms, { signal, bow: AUTOPILOT.bow, onFrame: step }); return; } // Long trips land a little past the target, then correct. const by = Math.min(distance * AUTOPILOT.overshoot, AUTOPILOT.overshootMax); const ux = (to.x - from.x) / distance; const uy = (to.y - from.y) / distance; const drift = (Math.random() - 0.5) * by * 0.8; const past = { x: to.x + ux * by - uy * drift, y: to.y + uy * by + ux * drift }; await glide(r, past, ms - AUTOPILOT.correct / 2, { signal, bow: AUTOPILOT.bow, onFrame: step }); await glide(r, to, AUTOPILOT.correct, { signal, ease: EASE_OUT, onFrame: step }); }; /** Goes to a target, unless the tip is already on it. Catches up if it moved on the way. */ const approach = async (r: Rig, element: Element, at: [number, number] | undefined, signal: AbortSignal) => { if (!at && inside(boxOf(element), tip(r))) return; await moveTo(r, aim(element, at), signal); if (!inside(boxOf(element), tip(r), 0)) await moveTo(r, aim(element, at), signal); }; const squash = (r: Rig, down: boolean) => { if (down) { animate(r.squash, AUTOPILOT.squash, { duration: 0.07, ease: "easeOut" }); if (!config.reduced) { r.ring.jump(0); animate(r.ring, 1, { duration: AUTOPILOT.ripple, ease: EASE_OUT }); } } else { animate( r.squash, 1, config.reduced ? { duration: 0.1 } : { type: "spring", stiffness: 600, damping: 18 }, ); } }; /** Presses and releases where the tip is. Once started it always finishes. */ const click = async (r: Rig, target: Element) => { const p = tip(r); const at = toClient(p); const under = hit(p); const on = under && target.contains(under) ? under : target; acted(); flag(target, "press", true); squash(r, true); fire(on, "pointerdown", at); fire(on, "mousedown", at); await sleep(AUTOPILOT.press); flag(target, "press", false); squash(r, false); acted(); fire(on, "pointerup", at); fire(on, "mouseup", at); fire(on, "click", at); // A click on a field shows it focused; anywhere else clears it. focusOn(r, on.closest("input, textarea")); }; const typeInto = async (r: Rig, step: { type: string; into: string; at?: [number, number] }, signal: AbortSignal) => { const target = await find(step.into, signal); if (!target) return; const field = fieldIn(target); if (!field) return warn(`"${step.into}" has no input or textarea to type into.`); if (r.focus !== field) { const box = field.getClientRects().length ? field : target; await approach(r, box, step.at, signal); await sleep(vary(AUTOPILOT.beat, 0.3), signal); await click(r, box); } focusOn(r, field); if (!config.reduced) { // Ease the pointer off the line, so the words show as they're typed. const p = tip(r); const below = boxOf(field).bottom + 4; glide( r, { x: p.x + 10, y: clamp(Math.max(p.y + 6, below), 0, height.get() - 40) }, 280, { ease: EASE_OUT }, ).catch(noop); } await sleep(AUTOPILOT.key * 2, signal); const keys = keysOf(step.type); let burst = Math.random() < AUTOPILOT.burst ? 3 : 0; for (let i = 0; i < keys.length; i++) { const key = keys[i]; press(field, key); if (i === keys.length - 1) break; let gap = vary(AUTOPILOT.key, 0.45); if (burst > 0) { burst--; gap *= 0.4; } if (key === " ") { gap += vary(AUTOPILOT.space, 0.5); if (Math.random() < AUTOPILOT.burst) burst = 2 + Math.floor(Math.random() * 3); } else if (/[.,!?;:…]/.test(key)) { gap += vary(AUTOPILOT.stop, 0.4); } await sleep(gap, signal); } }; const drag = async ( r: Rig, step: { drag: string; to: string; drop?: () => void; at?: [number, number] }, signal: AbortSignal, ) => { const item = await find(step.drag, signal); const zone = item && (await find(step.to, signal)); if (!(item instanceof HTMLElement) || !zone) return; await approach(r, item, step.at, signal); await sleep(AUTOPILOT.beat, signal); // Carry the grab point so the item's middle lands on the drop target's. const grab = tip(r); const from = boxOf(item); const onto = boxOf(zone); const end = { x: onto.left + onto.width / 2 + (grab.x - (from.left + from.width / 2)), y: onto.top + onto.height / 2 + (grab.y - (from.top + from.height / 2)), }; const held = lift(item); flag(item, "drag", true); squash(r, true); if (!config.reduced) await animate(0, 1, { duration: 0.14, ease: "easeOut", onUpdate: held.raise }); // The drop target shows data-autopilot-drop while the item is over it. let over = false; const hovering = (p: Point) => { const under = hit(p); const now = !!under && zone.contains(under); if (now !== over) flag(zone, "drop", (over = now)); }; try { await moveTo(r, end, signal, { carry: true, onFrame: (p) => { held.move(p.x - grab.x, p.y - grab.y); hovering(p); }, }); hovering(tip(r)); await sleep(AUTOPILOT.beat, signal); } catch (error) { // Taken over mid-drag: put it back. if (over) flag(zone, "drop", false); squash(r, false); const p = tip(r); const back = { x: p.x - grab.x, y: p.y - grab.y }; animate(1, 0, { ...AUTOPILOT.settle, onUpdate: (v) => { held.move(back.x * v, back.y * v); held.raise(v); }, onComplete: () => { held.restore(); flag(item, "drag", false); }, }); throw error; } // Set it down: let the UI move it, then settle it from where it was dropped into its new place. if (over) flag(zone, "drop", false); squash(r, false); const dropped = item.getBoundingClientRect(); step.drop?.(); await nextFrame(); await nextFrame(); const placed = content?.querySelector<HTMLElement>(`[data-autopilot="${CSS.escape(step.drag)}"]`); let settling = held; if (placed && placed !== item) { held.restore(); flag(item, "drag", false); flag(placed, "drag", true); settling = lift(placed); settling.raise(1); } const target = settling.element; settling.move(0, 0); const now = target.getBoundingClientRect(); const f = frame(); const offset = { x: (dropped.left + dropped.width / 2 - (now.left + now.width / 2)) / f.k, y: (dropped.top + dropped.height / 2 - (now.top + now.height / 2)) / f.k, }; const finish = () => { settling.restore(); flag(target, "drag", false); }; if (config.reduced) return finish(); settling.move(offset.x, offset.y); animate(1, 0, { ...AUTOPILOT.settle, onUpdate: (v) => { settling.move(offset.x * v, offset.y * v); settling.raise(v); }, onComplete: finish, }); }; /** One cursor's steps in a chapter, in order. */ const walk = async (id: string, steps: AutopilotStep[], signal: AbortSignal) => { const r = rig(id); for (const step of steps) { if (signal.aborted) throw signal.reason; if (step.say !== undefined) ui.onSay(id, step.say); if ("move" in step) { const target = await find(step.move, signal); if (target) await approach(r, target, step.at, signal); } else if ("click" in step) { const target = await find(step.click, signal); if (!target) continue; await approach(r, target, step.at, signal); await sleep(vary(AUTOPILOT.beat, 0.3), signal); await click(r, target); } else if ("type" in step) { await typeInto(r, step, signal); } else if ("drag" in step) { await drag(r, step, signal); } else if ("wait" in step) { await sleep(step.wait, signal); } else if ("do" in step) { await step.do(); } } }; const estimate = (tracks: AutopilotStep[][]) => Math.max( 1000, ...tracks.map((steps) => steps.reduce( (ms, step) => ms + ("wait" in step ? step.wait : "type" in step ? 900 + step.type.length * 95 : "drag" in step ? 2000 : "click" in step ? 1000 : "move" in step ? 700 : 0), 0, ), ), ); /** Plays a chapter: every cursor's steps at once, each cursor's in order. */ const play = async (index: number, signal: AbortSignal) => { const lead = config.cursors[0]?.id; const tracks = new Map<string, AutopilotStep[]>(); for (const step of config.chapters[index].steps) { const id = step.cursor ?? lead; if (!id) continue; if (!config.cursors.some((cursor) => cursor.id === id)) { warn(`no cursor with id "${id}".`); continue; } tracks.set(id, [...(tracks.get(id) ?? []), step]); } fill?.stop(); progress.jump(0); const expected = measured[index] ?? estimate([...tracks.values()]); fill = animate(progress, 0.96, { duration: expected / 1000, ease: "linear" }); const began = performance.now(); const results = await Promise.allSettled( [...tracks].map(([id, steps]) => walk(id, steps, signal)), ); if (signal.aborted) throw signal.reason; for (const result of results) if (result.status === "rejected") throw result.reason; measured[index] = performance.now() - began; fill.stop(); fill = animate(progress, 1, { duration: 0.2, ease: "easeOut" }); }; /** Crossfades from the stage as it is to the chapter's setup. */ const reset = async (index: number, signal: AbortSignal) => { const copy = content && host ? snapshot(content, host) : null; try { unflagAll(); config.chapters[index].setup?.(); await nextFrame(signal); if (copy) await animate(copy, { opacity: [1, 0] }, { duration: AUTOPILOT.fade, ease: "easeOut" }); } finally { copy?.remove(); } }; const goHome = (signal: AbortSignal) => Promise.all( crew().map(async (r, i) => { await moveTo(r, home(i), signal); r.placed = "home"; }), ); const park = async () => { parked = true; ui.onParked(true); unflagAll(); await Promise.all( crew().map(async (r, i) => { r.placed = "park"; animate(r.rest, 1, { duration: 0.3 }); if (!config.reduced) return glide(r, spot(i), AUTOPILOT.yield, { bow: AUTOPILOT.bow, ease: EASE_OUT }); r.stop(); r.x.jump(spot(i).x); r.y.jump(spot(i).y); }), ); }; const unpark = () => { parked = false; ui.onParked(false); for (const r of crew()) animate(r.rest, 0, { duration: 0.25 }); }; const run = async (signal: AbortSignal) => { if (signal.aborted || !stage) return; try { if (parked) unpark(); if (pending !== null) { chapter = pending; pending = null; ended = false; } for (;;) { const count = config.chapters.length; if (!count) return; if (ended || chapter >= count) { ended = false; chapter = 0; ui.onQuiet(); await Promise.all([reset(0, signal), goHome(signal)]); } else if (dirty) { await reset(chapter, signal); } else { config.chapters[chapter].setup?.(); await nextFrame(signal); } dirty = true; const index = chapter; ui.onChapter(index); config.onChapterChange?.(index); ui.onQuiet(); await play(index, signal); chapter = index + 1; if (chapter >= config.chapters.length) { ended = true; if (!config.loop || config.reduced) return ui.onFinish(); await sleep(AUTOPILOT.hold, signal); } } } catch (error) { if (!signal.aborted) throw error; fill?.stop(); if (signal.reason === "yield") await park(); } }; /** Puts cursors that haven't been seen yet at home and fades them in. */ const show = () => { if (!stage) return; config.cursors.forEach((cursor, i) => { const r = rig(cursor.id); if (r.placed || r.opacity.get() > 0) return; r.x.jump(home(i).x); r.y.jump(home(i).y); r.placed = "home"; if (config.reduced) r.opacity.jump(1); else animate(r.opacity, 1, { duration: 0.4, ease: "easeOut" }); }); }; return { progress, width, height, rig, configure(next: PilotConfig) { config = next; show(); }, listen(next: PilotUI) { ui = next; return () => { ui = { onChapter: noop, onSay: noop, onQuiet: noop, onParked: noop, onFinish: noop }; }; }, connect(nextStage: HTMLElement | null, nextContent: HTMLElement | null, nextHost: HTMLElement | null) { if (!nextStage || !nextContent || !nextHost) return; stage = nextStage; content = nextContent; host = nextHost; const measure = () => { width.set(nextStage.clientWidth); height.set(nextStage.clientHeight); config.cursors.forEach((cursor, i) => { const r = rig(cursor.id); const at = r.placed === "home" ? home(i) : r.placed === "park" ? spot(i) : null; if (!at) return; r.stop(); r.x.jump(at.x); r.y.jump(at.y); }); }; measure(); show(); const observer = new ResizeObserver(measure); observer.observe(nextStage); return () => { observer.disconnect(); unflagAll(); stage = content = host = null; }; }, /** Starts a run once the one before it has finished the action it was in. */ start(signal: AbortSignal) { queue = queue .then(() => run(signal)) .catch((error) => { if (!signal.aborted) console.error(error); }); }, /** Why a run is being stopped: the visitor taking over, or anything else. */ halt: () => (config.yielding ? "yield" : "stop"), seek(index: number) { pending = index; dirty = true; fill?.stop(); progress.jump(0); }, /** Whether the UI is reacting to something a cursor just did, like a menu focusing itself. */ acting: () => performance.now() < actingUntil, }; } type Pilot = ReturnType<typeof createPilot>; const REDUCED = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; /** Reduced motion, read so the server and the first client render agree. */ function useReduced() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } /* ── Autopilot ──────────────────────────────────────────── */ export interface AutopilotProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The cursors on the stage. Steps without a `cursor` belong to the first. */ cursors: AutopilotCursor[]; /** The script, in chapters. Each starts from its `setup`, so a takeover can replay it. */ chapters: AutopilotChapter[]; /** The UI the cursors use. Mark targets with `data-autopilot="<name>"`. */ children: React.ReactNode; /** Show the chapters under the stage, filling like a story timer. Click one to jump to it. */ chapterStrip?: boolean; /** Start over after the last chapter. Otherwise it stops on the finished state. */ loop?: boolean; /** The parked cursor's label while the visitor has control. */ yieldLabel?: string; /** The corner the cursors step aside to. Pick one your UI keeps clear. */ park?: AutopilotCorner; /** Called when a chapter starts. */ onChapterChange?: (index: number) => void; /** Classes for the stage, the box the cursors move in. */ stageClassName?: string; } /** * A cursor that uses your UI on its own: it moves, clicks, types and drags * across real markup on a script, and steps aside when a visitor wants to try it. */ export function Autopilot({ cursors, chapters, children, chapterStrip = false, loop = true, yieldLabel = "Your turn", park = "bottom-right", onChapterChange, stageClassName, className, ...props }: AutopilotProps) { const reduced = useReduced(); const [pilot] = React.useState(createPilot); const stageRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null); const hostRef = React.useRef<HTMLDivElement>(null); const [chapter, setChapter] = React.useState(0); const [says, setSays] = React.useState<Record<string, string | null>>({}); const [parked, setParked] = React.useState(false); const [intent, setIntent] = React.useState<boolean | null>(null); // the visitor's play or pause, once pressed const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); const [touched, setTouched] = React.useState(false); const [seen, setSeen] = React.useState(false); const [jump, setJump] = React.useState(0); const playing = intent ?? !reduced; const yielded = hovered || focused || touched || !playing; const running = seen && !yielded; React.useLayoutEffect(() => { pilot.configure({ cursors, chapters, reduced, loop, park, yielding: yielded, onChapterChange }); }); React.useLayoutEffect( () => pilot.listen({ onChapter: setChapter, onSay: (id, text) => setSays((current) => ({ ...current, [id]: text })), onQuiet: () => setSays({}), onParked: setParked, onFinish: () => setIntent(false), }), [pilot], ); React.useLayoutEffect( () => pilot.connect(stageRef.current, contentRef.current, hostRef.current), [pilot], ); React.useEffect(() => { if (!running) return; const run = new AbortController(); pilot.start(run.signal); return () => run.abort(pilot.halt()); }, [pilot, running, jump]); // Off screen or in a background tab, it stops, and replays the chapter when it's back. React.useEffect(() => { const stage = stageRef.current; if (!stage) return; let inView = false; const update = () => setSeen(inView && document.visibilityState === "visible"); const observer = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; update(); }, { threshold: 0.2 }, ); observer.observe(stage); document.addEventListener("visibilitychange", update); return () => { observer.disconnect(); document.removeEventListener("visibilitychange", update); }; }, []); /* The visitor: a mouse inside or focus inside holds it until two seconds after they leave; a tap holds it until Resume. */ const timers = React.useRef({ hover: 0, focus: 0, pointer: "mouse" }); React.useEffect(() => { const t = timers.current; return () => { window.clearTimeout(t.hover); window.clearTimeout(t.focus); }; }, []); const holdHover = () => { window.clearTimeout(timers.current.hover); setHovered(true); }; const letGo = (kind: "hover" | "focus") => { const t = timers.current; window.clearTimeout(t[kind]); t[kind] = window.setTimeout( () => (kind === "hover" ? setHovered(false) : setFocused(false)), AUTOPILOT.resume, ); }; const resume = () => { const t = timers.current; window.clearTimeout(t.hover); window.clearTimeout(t.focus); setHovered(false); setFocused(false); setTouched(false); setIntent(true); const active = document.activeElement; if (active instanceof HTMLElement && stageRef.current?.contains(active)) active.blur(); }; const seek = (index: number) => { pilot.seek(index); setChapter(index); resume(); setJump((n) => n + 1); }; const control: ControlState = touched ? "resume" : playing ? "pause" : "play"; const onControl = () => (control === "pause" ? setIntent(false) : resume()); return ( <div {...props} className={cn("relative", className)}> <div className="relative"> <div ref={stageRef} className={cn("relative", stageClassName)} onPointerEnter={(event) => { if (event.nativeEvent.isTrusted && event.pointerType === "mouse") holdHover(); }} onPointerMove={(event) => { if (event.nativeEvent.isTrusted && event.pointerType === "mouse" && !hovered) holdHover(); }} onPointerLeave={(event) => { if (event.nativeEvent.isTrusted && event.pointerType === "mouse") letGo("hover"); }} onPointerDown={(event) => { if (event.nativeEvent.isTrusted) timers.current.pointer = event.pointerType; }} onClickCapture={(event) => { if (event.nativeEvent.isTrusted && timers.current.pointer !== "mouse") setTouched(true); }} onFocus={() => { if (pilot.acting()) return; window.clearTimeout(timers.current.focus); setFocused(true); }} onBlur={(event) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; letGo("focus"); }} > <div ref={contentRef} className="relative isolate h-full"> {children} </div> <div ref={hostRef} aria-hidden className="pointer-events-none absolute inset-0 z-10" /> <div aria-hidden className="pointer-events-none absolute inset-0 z-20"> {cursors.map((cursor, i) => { const said = says[cursor.id]; const text = parked ? (i === 0 ? yieldLabel : null) : said === undefined ? cursor.label : (said ?? cursor.label); return ( <Pointer key={cursor.id} id={cursor.id} rig={pilot.rig(cursor.id)} pilot={pilot} color={cursor.color} text={text || null} reduced={reduced} /> ); })} </div> </div> {!chapterStrip && ( <Control state={control} onClick={onControl} className="absolute bottom-3 left-3 z-30 bg-zinc-950/80 text-white shadow-sm ring-1 ring-white/10 backdrop-blur-sm hover:bg-zinc-950 dark:bg-white/85 dark:text-zinc-900 dark:ring-black/5 dark:hover:bg-white" /> )} </div> {chapterStrip && ( <div className="mt-4 flex items-start gap-3" role="group" aria-label="Chapters"> <Control state={control} onClick={onControl} className="mt-0.5 shrink-0 ring-1 ring-current/15 hover:bg-current/5" /> <ol className="grid min-w-0 flex-1 gap-2 sm:gap-3" style={{ gridTemplateColumns: `repeat(${chapters.length}, minmax(0, 1fr))` }} > {chapters.map((item, i) => ( <li key={i} className="min-w-0"> <button type="button" onClick={() => seek(i)} aria-current={i === chapter ? "step" : undefined} className="group/chapter block w-full rounded-sm py-1.5 text-left outline-current focus-visible:outline-2 focus-visible:outline-offset-2" > <span className="block h-[3px] overflow-hidden rounded-full bg-current/15"> <motion.span className="block h-full origin-left rounded-full bg-current" style={{ scaleX: i < chapter ? 1 : i === chapter ? pilot.progress : 0 }} /> </span> <span className={cn( "mt-2 flex items-baseline gap-1.5 text-xs transition-opacity duration-200", i === chapter ? "opacity-100" : "opacity-50 group-hover/chapter:opacity-80", )} > <span className="font-mono tabular-nums opacity-60"> {String(i + 1).padStart(2, "0")} </span> <span className="truncate font-medium">{item.title}</span> </span> </button> </li> ))} </ol> </div> )} </div> ); } /* ── Parts ──────────────────────────────────────────────── */ function Pointer({ id, rig, pilot, color, text, reduced, }: { id: string; rig: Rig; pilot: Pilot; color: string; text: string | null; reduced: boolean; }) { // Keep the last words while the pill fades out, so it doesn't empty first. const [kept, setKept] = React.useState(text); if (text !== null && text !== kept) setKept(text); const shown = text ?? kept; const measureRef = React.useRef<HTMLSpanElement>(null); const pill = useMotionValue(0); React.useEffect(() => { const element = measureRef.current; if (!element) return; let first = true; const observer = new ResizeObserver(() => { const w = element.offsetWidth; if (!w) return; if (first || reduced) pill.jump(w); else animate(pill, w, AUTOPILOT.label); first = false; }); observer.observe(element); return () => observer.disconnect(); }, [pill, reduced]); // Slides along the stage's edge instead of hanging off it, and moves above the tip near the bottom. const left = useTransform(() => { const x = rig.x.get(); const max = pilot.width.get() - pill.get() - PILL.inset; return Math.max(PILL.inset, Math.min(x + PILL.x, max)) - x; }); const flip = useTransform((): number => rig.y.get() + PILL.y + PILL.height + PILL.inset > pilot.height.get() ? 1 : 0, ); const flipped = useSpring(flip, { stiffness: 500, damping: 40 }); const top = useTransform(() => { const f = reduced ? flip.get() : flipped.get(); return PILL.y + f * (-PILL.height - 8 - PILL.y); }); const arrowOpacity = useTransform(rig.rest, [0, 1], [1, AUTOPILOT.parked]); const ringScale = useTransform(rig.ring, [0, 1], [0.25, 1]); const ringOpacity = useTransform(rig.ring, [0, 0.15, 1], [0, 0.6, 0]); return ( <motion.div data-autopilot-cursor={id} className="absolute top-0 left-0 will-change-transform" style={{ x: rig.x, y: rig.y, opacity: rig.opacity }} > <motion.span className="absolute -top-4 -left-4 size-8 rounded-full border-2" style={{ borderColor: color, scale: ringScale, opacity: ringOpacity }} /> <motion.span className="absolute -top-[2px] -left-[2px] block origin-[2px_2px]" style={{ scale: rig.squash, opacity: arrowOpacity, filter: "drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.3)) drop-shadow(0 4px 8px rgb(0 0 0 / 0.12))", }} > <svg width="24" height="26" viewBox="0 0 24 26" fill="none" className="block overflow-visible"> <path d="M2 2 L2 21.8 L7.6 16.9 L16.6 16.5 Z" fill={color} stroke="white" strokeWidth="1.5" strokeLinejoin="round" /> </svg> </motion.span> <motion.div className="absolute top-0 left-0 h-6 origin-top-left overflow-hidden rounded-full shadow-sm" style={{ x: left, y: top, width: pill, backgroundColor: color }} initial={false} animate={{ opacity: text ? 1 : 0, scale: text ? 1 : 0.85 }} transition={{ duration: 0.2, ease: "easeOut" }} > <AnimatePresence initial={false}> <motion.span key={shown} className="absolute inset-y-0 left-0 flex items-center px-2.5 text-xs font-medium whitespace-nowrap text-white" initial={reduced ? { opacity: 0 } : { y: "100%", opacity: 0 }} animate={{ y: "0%", opacity: 1 }} exit={reduced ? { opacity: 0 } : { y: "-100%", opacity: 0 }} transition={reduced ? { duration: 0.15 } : AUTOPILOT.label} > {shown} </motion.span> </AnimatePresence> </motion.div> <span ref={measureRef} className="invisible absolute top-0 left-0 px-2.5 text-xs font-medium whitespace-nowrap" > {shown} </span> </motion.div> ); } type ControlState = "pause" | "play" | "resume"; function Control({ state, onClick, className, }: { state: ControlState; onClick: () => void; className?: string; }) { return ( <button type="button" onClick={onClick} className={cn( "inline-flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors outline-current focus-visible:outline-2 focus-visible:outline-offset-2", state === "pause" ? "px-0" : "pr-3 pl-2.5", className, )} > {state === "pause" ? ( <svg viewBox="0 0 12 12" className="size-3" fill="currentColor" aria-hidden> <rect x="2.5" y="2" width="2.5" height="8" rx="0.75" /> <rect x="7" y="2" width="2.5" height="8" rx="0.75" /> </svg> ) : ( <svg viewBox="0 0 12 12" className="size-3" fill="currentColor" aria-hidden> <path d="M3.5 2.2v7.6a.6.6 0 0 0 .9.5l6-3.8a.6.6 0 0 0 0-1L4.4 1.7a.6.6 0 0 0-.9.5Z" /> </svg> )} <span className={state === "pause" ? "sr-only" : undefined}> {state === "pause" ? "Pause" : state === "resume" ? "Resume" : "Play"} </span> </button> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Autopilot. Each one installs whole, with the component included.
Design Review
Two teammates on a design canvas, with their steps running at the same time. Maya drags the Features frame into place, and its snap guides show through `data-autopilot-drop`, while Leo drops a comment pin and asks a question. Then Maya replies and Leo likes it. Move your mouse in and both step aside, and the canvas is yours: drag the frame or leave a comment.
Landing v3 / Review
Layers
Hero
Pricing
Features
"use client";
import * as React from "react";
import { Autopilot, type AutopilotChapter } from "@/components/ui/autopilot";
const team = {
maya: { name: "Maya", color: "#e0457b" },
leo: { name: "Leo", color: "#2f7cf6" },
you: { name: "You", color: "#18181b" },
};
type Author = keyof typeof team;
type Pos = { x: number; y: number }; // percent of the canvas
const SIZE = { w: 43, h: 38 };
const HERO: Pos = { x: 5, y: 9 };
const PRICING: Pos = { x: 52, y: 9 };
const SLOT: Pos = { x: 5, y: 55 }; // under the hero, on its left edge
const ASTRAY: Pos = { x: 55, y: 58 };
const PIN: Pos = { x: 33, y: 27 };
type Message = { author: Author; text: string };
type Board = {
features: Pos;
tool: "move" | "comment";
pin: Pos | null;
draft: string;
author: Author;
messages: Message[];
liked: boolean;
};
const fresh: Board = { features: ASTRAY, tool: "move", pin: null, draft: "", author: "you", messages: [], liked: false };
const asked: Board = {
...fresh,
features: SLOT,
pin: PIN,
messages: [{ author: "leo", text: "Can we try a darker hero image?" }],
};
const near = (a: Pos, b: Pos) => Math.abs(a.x - b.x) < 4 && Math.abs(a.y - b.y) < 5;
export function AutopilotMultiplayerDemo() {
const [board, setBoard] = React.useState(fresh);
const [snapping, setSnapping] = React.useState(false);
const update = (patch: Partial<Board>) => setBoard((current) => ({ ...current, ...patch }));
const canvasRef = React.useRef<HTMLDivElement>(null);
const grab = React.useRef<{ x: number; y: number; from: Pos } | null>(null);
const chapters: AutopilotChapter[] = [
{
title: "Arrange",
setup: () => setBoard(fresh),
steps: [
{ cursor: "maya", move: "features", at: [0.3, 0.2] },
{
cursor: "maya",
drag: "features",
to: "slot",
drop: () => update({ features: SLOT }),
},
{ cursor: "maya", move: "pricing", at: [0.4, 0.8] },
{ cursor: "leo", wait: 500 },
{ cursor: "leo", click: "tool-comment" },
{ cursor: "leo", click: "hero", at: [0.65, 0.48] },
{ cursor: "leo", do: () => update({ author: "leo" }) },
{ cursor: "leo", type: "Can we try a darker hero image?", into: "reply" },
{ cursor: "leo", click: "post" },
],
},
{
title: "Reply",
setup: () => setBoard(asked),
steps: [
{ cursor: "maya", do: () => update({ author: "maya" }) },
{ cursor: "maya", type: "On it, trying v4 now", into: "reply" },
{ cursor: "maya", click: "post" },
{ cursor: "maya", move: "pricing", at: [0.3, 0.75] },
{ cursor: "leo", wait: 2600 },
{ cursor: "leo", click: "like" },
{ cursor: "leo", wait: 1200 },
],
},
];
const post = () => {
const text = board.draft.trim();
if (!text) return;
update({ messages: [...board.messages, { author: board.author, text }], draft: "", author: "you" });
};
const place = (event: React.MouseEvent) => {
const canvas = canvasRef.current?.getBoundingClientRect();
if (board.tool !== "comment" || !canvas) return;
update({
tool: "move",
pin: {
x: ((event.clientX - canvas.left) / canvas.width) * 100,
y: ((event.clientY - canvas.top) / canvas.height) * 100,
},
messages: [],
liked: false,
});
};
// The visitor drags the frame themselves; it snaps when it's close to the hero's edge.
const dragHandlers = {
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => {
if (board.tool !== "move" || !event.nativeEvent.isTrusted) return;
event.currentTarget.setPointerCapture(event.pointerId);
grab.current = { x: event.clientX, y: event.clientY, from: board.features };
},
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {
const start = grab.current;
const canvas = canvasRef.current?.getBoundingClientRect();
if (!start || !canvas) return;
const next = {
x: start.from.x + ((event.clientX - start.x) / canvas.width) * 100,
y: start.from.y + ((event.clientY - start.y) / canvas.height) * 100,
};
setSnapping(near(next, SLOT));
update({ features: next });
},
onPointerUp: () => {
if (!grab.current) return;
grab.current = null;
setSnapping(false);
setBoard((current) => (near(current.features, SLOT) ? { ...current, features: SLOT } : current));
},
};
const threadLeft = board.pin && board.pin.x > 55;
return (
<Autopilot
cursors={[
{ id: "maya", label: team.maya.name, color: team.maya.color, home: [0.86, 0.5] },
{ id: "leo", label: team.leo.name, color: team.leo.color, home: [0.42, 0.9] },
]}
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="flex h-[28rem] flex-col">
{/* Toolbar */}
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-zinc-200 px-3 dark:border-white/10">
<span className="grid size-6 place-items-center rounded-md bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<svg viewBox="0 0 12 12" className="size-3" fill="currentColor" aria-hidden>
<path d="M6 1 11 6 6 11 1 6Z" />
</svg>
</span>
<p className="truncate text-sm font-medium">
Landing v3 <span className="hidden text-zinc-400 sm:inline">/ Review</span>
</p>
<div className="mx-auto flex items-center gap-0.5 rounded-lg bg-zinc-100 p-0.5 dark:bg-white/5">
<Tool label="Move" active={board.tool === "move"} onClick={() => update({ tool: "move" })}>
<path d="M3 2.5 12 7 7.6 8 6 12.5Z" />
</Tool>
<Tool
label="Comment"
name="tool-comment"
active={board.tool === "comment"}
onClick={() => update({ tool: board.tool === "comment" ? "move" : "comment" })}
>
<path d="M2.5 3.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H6l-2.5 2v-2h0a1 1 0 0 1-1-1Z" />
</Tool>
</div>
<div className="flex -space-x-1.5">
{(["maya", "leo", "you"] as const).map((id) => (
<span
key={id}
title={team[id].name}
style={{ backgroundColor: team[id].color }}
className="grid size-6 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-white dark:ring-zinc-900"
>
{team[id].name[0]}
</span>
))}
</div>
<span className="hidden h-7 items-center rounded-md bg-[#2f7cf6] px-2.5 text-xs font-medium text-white sm:inline-flex">
Share
</span>
</div>
<div className="flex min-h-0 flex-1">
{/* Layers */}
<div className="hidden w-36 shrink-0 border-r border-zinc-200 p-3 text-xs sm:block dark:border-white/10">
<p className="mb-2 text-[10px] font-medium tracking-wide text-zinc-400 uppercase">Layers</p>
{["Hero", "Pricing", "Features"].map((layer) => (
<p key={layer} className="flex items-center gap-2 rounded px-1.5 py-1 text-zinc-600 dark:text-zinc-300">
<span className="size-2.5 rounded-[3px] border border-current opacity-60" />
{layer}
</p>
))}
</div>
{/* Canvas */}
<div
ref={canvasRef}
onClick={place}
className={`relative min-w-0 flex-1 overflow-hidden bg-zinc-100 dark:bg-zinc-950 ${board.tool === "comment" ? "cursor-crosshair" : ""}`}
style={{
backgroundImage: "radial-gradient(circle, rgb(120 120 130 / 0.22) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
>
<Frame name="hero" label="Hero" at={HERO}>
<div className="flex h-full gap-2">
<div className="flex flex-1 flex-col justify-center gap-1.5">
<span className="h-2 w-4/5 rounded-sm bg-zinc-800 dark:bg-zinc-200" />
<span className="h-2 w-3/5 rounded-sm bg-zinc-800 dark:bg-zinc-200" />
<span className="mt-1 h-1 w-4/5 rounded-sm bg-zinc-300 dark:bg-zinc-700" />
<span className="h-1 w-2/3 rounded-sm bg-zinc-300 dark:bg-zinc-700" />
<span className="mt-1.5 h-3 w-10 rounded-full bg-[#2f7cf6]" />
</div>
<div className="w-2/5 rounded-md bg-gradient-to-br from-sky-200 via-indigo-200 to-rose-200 dark:from-sky-900 dark:via-indigo-900 dark:to-rose-900" />
</div>
</Frame>
<Frame name="pricing" label="Pricing" at={PRICING}>
<div className="grid h-full grid-cols-3 gap-1.5">
{[0, 1, 2].map((i) => (
<div
key={i}
className={`flex flex-col gap-1 rounded-md border p-1.5 ${i === 1 ? "border-[#2f7cf6]/60 bg-[#2f7cf6]/5" : "border-zinc-200 dark:border-white/10"}`}
>
<span className="h-1 w-2/3 rounded-sm bg-zinc-300 dark:bg-zinc-600" />
<span className="h-2 w-1/2 rounded-sm bg-zinc-800 dark:bg-zinc-200" />
<span className="mt-auto h-2 rounded-sm bg-zinc-200 dark:bg-zinc-700" />
</div>
))}
</div>
</Frame>
{/* Where the features frame belongs: guides show as it nears */}
<div
data-autopilot="slot"
data-snapping={snapping || undefined}
className="group/slot absolute"
style={{ left: `${SLOT.x}%`, top: `${SLOT.y}%`, width: `${SIZE.w}%`, height: `${SIZE.h}%` }}
>
<div className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-150 group-data-[autopilot-drop]/slot:opacity-100 group-data-[snapping]/slot:opacity-100">
<span className="absolute -top-[1.2rem] bottom-0 -left-px w-px bg-[#e0457b]" />
<span className="absolute -top-[1.2rem] left-1/2 h-[1.2rem] w-px -translate-x-1/2 bg-[#e0457b]" />
<span className="absolute -top-[1.05rem] left-1/2 ml-1 rounded-sm bg-[#e0457b] px-1 text-[9px] leading-3.5 font-medium text-white tabular-nums">
24
</span>
</div>
</div>
<Frame
name="features"
label="Features"
at={board.features}
className="touch-none data-[autopilot-drag]:outline-2 data-[autopilot-drag]:outline-[#e0457b]"
{...dragHandlers}
>
<div className="grid h-full grid-cols-2 gap-x-2 gap-y-1.5">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-1.5">
<span className="size-3 shrink-0 rounded-[4px] bg-indigo-200 dark:bg-indigo-800" />
<span className="flex flex-1 flex-col gap-1 pt-0.5">
<span className="h-1 w-4/5 rounded-sm bg-zinc-700 dark:bg-zinc-300" />
<span className="h-1 w-3/5 rounded-sm bg-zinc-300 dark:bg-zinc-700" />
</span>
</div>
))}
</div>
</Frame>
{/* Comment */}
{board.pin && (
<>
<span
aria-hidden
className="absolute z-10 grid size-6 -translate-y-full place-items-center rounded-full rounded-bl-none text-[10px] font-semibold text-white shadow-md ring-2 ring-white dark:ring-zinc-900"
style={{
left: `${board.pin.x}%`,
top: `${board.pin.y}%`,
backgroundColor: team[board.messages[0]?.author ?? board.author].color,
}}
>
{team[board.messages[0]?.author ?? board.author].name[0]}
</span>
<div
onClick={(event) => event.stopPropagation()}
className="absolute z-20 rounded-xl border border-zinc-200 bg-white p-2.5 text-xs shadow-xl dark:border-white/10 dark:bg-zinc-800"
style={{
top: `calc(${board.pin.y}% - 1.5rem)`,
...(threadLeft
? { right: `calc(${100 - board.pin.x}% + 0.5rem)`, width: `min(13rem, ${board.pin.x}% - 1rem)` }
: { left: `calc(${board.pin.x}% + 2rem)`, width: `min(13rem, ${100 - board.pin.x}% - 2.5rem)` }),
}}
>
{board.messages.map((message, i) => (
<div key={i} className="mb-2.5 flex gap-2">
<span
style={{ backgroundColor: team[message.author].color }}
className="grid size-5 shrink-0 place-items-center rounded-full text-[9px] font-semibold text-white"
>
{team[message.author].name[0]}
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px]">
<span className="font-semibold">{team[message.author].name}</span>
<span className="ml-1.5 text-zinc-400">now</span>
</p>
<p className="mt-0.5 leading-snug text-pretty text-zinc-700 dark:text-zinc-200">
{message.text}
</p>
{message.author === "maya" && (
<button
type="button"
data-autopilot="like"
onClick={() => update({ liked: !board.liked })}
className={`mt-1.5 inline-flex h-5 items-center gap-1 rounded-full border px-1.5 text-[10px] transition-colors data-[autopilot-hover]:border-zinc-300 ${
board.liked
? "border-[#2f7cf6]/40 bg-[#2f7cf6]/10 text-[#2f7cf6]"
: "border-zinc-200 text-zinc-500 dark:border-white/10"
}`}
>
👍 {board.liked ? 1 : ""}
</button>
)}
</div>
</div>
))}
<form
className="flex items-center gap-1.5"
onSubmit={(event) => {
event.preventDefault();
post();
}}
>
<input
data-autopilot="reply"
value={board.draft}
onChange={(event) => update({ draft: event.target.value })}
placeholder={board.messages.length ? "Reply" : "Add a comment"}
aria-label="Comment"
className="h-7 min-w-0 flex-1 rounded-md border border-zinc-200 bg-transparent px-2 text-base outline-none placeholder:text-zinc-400 focus:border-[#2f7cf6] data-[autopilot-focus]:border-[#2f7cf6] sm:text-xs dark:border-white/10"
/>
<button
type="submit"
data-autopilot="post"
aria-label="Post"
disabled={!board.draft.trim()}
className="grid size-7 shrink-0 place-items-center rounded-md bg-[#2f7cf6] text-white transition-[opacity,scale] disabled:opacity-40 data-[autopilot-press]:scale-95"
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" aria-hidden>
<path d="M6 10V2.5M2.5 6 6 2.5 9.5 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</form>
</div>
</>
)}
</div>
</div>
</div>
</Autopilot>
);
}
function Frame({
name,
label,
at,
className,
children,
...props
}: {
name: string;
label: string;
at: Pos;
className?: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
data-autopilot={name}
className={`absolute rounded-lg border border-zinc-200 bg-white p-2.5 shadow-sm dark:border-white/10 dark:bg-zinc-900 ${className ?? ""}`}
style={{ left: `${at.x}%`, top: `${at.y}%`, width: `${SIZE.w}%`, height: `${SIZE.h}%` }}
>
<span className="pointer-events-none absolute -top-4 left-0 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
{label}
</span>
{children}
</div>
);
}
function Tool({
label,
name,
active,
onClick,
children,
}: {
label: string;
name?: string;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
data-autopilot={name}
onClick={onClick}
className={`grid size-7 place-items-center rounded-md transition-colors data-[autopilot-hover]:bg-white dark:data-[autopilot-hover]:bg-white/10 ${
active
? "bg-white text-zinc-900 shadow-sm dark:bg-white/15 dark:text-white"
: "text-zinc-500 hover:bg-white/70 dark:hover:bg-white/10"
}`}
>
<svg viewBox="0 0 14 14" className="size-3.5" fill="currentColor" aria-hidden>
{children}
</svg>
</button>
);
}
Three Clicks
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.
Projects
Start a project
Pick a template. You can change everything later.
"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("");
Props
| Prop | Type | Default | Description |
|---|---|---|---|
cursorsRequired | AutopilotCursor[] | None | The cursors on the stage, as { id, label, color, home? }. label is the pill's text when the cursor isn't saying anything, and home is where it waits at the start and between loops, as fractions of the stage. Steps without a cursor belong to the first. |
chaptersRequired | AutopilotChapter[] | None | The script, as { title, setup?, steps }. setup puts the UI in the state the chapter starts from. It runs every time the chapter starts, so after a takeover, a jump or a loop the chapter replays from a known state. |
childrenRequired | React.ReactNode | None | The UI the cursors use. Mark targets with data-autopilot="<name>". While a cursor is on one it gets data-autopilot-hover, -press, -focus, -drag or -drop, so style them next to hover:, active: and focus:. |
chapterStrip | boolean | false | Show the chapters under the stage. The current one fills like a story timer, and clicking one jumps to it. The pause button moves into the strip. |
loop | boolean | true | Start over after the last chapter: the finished state holds for 2s, the stage crossfades back and the cursors glide home. With false it stops on the finished state and shows Play. |
park | "bottom-right" | "bottom-left" | "top-right" | "top-left" | "bottom-right" | The corner the cursors step aside to while the visitor has control, and wait in unless they have a home. Pick one your UI keeps clear. |
yieldLabel | string | "Your turn" | The parked cursor's label while the visitor has control. |
onChapterChange | (index: number) => void | None | Called as each chapter starts. |
stageClassName | string | None | Classes for the stage, the box the cursors move in. Borders, rounding and overflow-hidden go here. |
className | string | None | Classes for the outer wrapper, which also holds the chapter strip. The strip draws in the text color, so set text-* here to recolor it. |
step.move | string | None | AutopilotStep: glide to the target with this name and rest over it. |
step.click | string | None | AutopilotStep: go to the target, wait a beat, then press and release. Pointer, mouse and click events fire on it with real coordinates, so the UI's own handlers run. |
step.type / step.into | string | None | AutopilotStep: click into the input or textarea named by into (or the one inside it) and type the text at a person's pace, so onChange runs per key. {Enter} and {Backspace} press those keys. Real focus never moves; the field gets data-autopilot-focus. |
step.drag / step.to | string | None | AutopilotStep: lift the target, carry it onto the to target and set it down. The item moves with translate and scale while it's held. |
step.drop | () => void | None | AutopilotStep: with drag, called as the item is set down. Move it in your state here; it settles from the drop point into its new place. |
step.wait | number | None | AutopilotStep: rest for this many ms. |
step.do | () => void | Promise<void> | None | AutopilotStep: run a callback for state the demo owns. A returned promise is awaited. |
step.cursor | string | None | AutopilotStep: the cursor that takes the step. Each cursor takes its own steps in order, and different cursors' steps run at the same time. |
step.say | string | null | None | AutopilotStep: change the cursor's label as the step starts. It stays until the next say; null goes back to the cursor's label. |
step.at | [number, number] | None | AutopilotStep: where on the target to point, as fractions of its width and height. By default it lands near the middle, never dead center, and toward the start of a text field. |
cursorsRequiredType
AutopilotCursor[]The cursors on the stage, as
{ id, label, color, home? }.labelis the pill's text when the cursor isn't saying anything, andhomeis where it waits at the start and between loops, as fractions of the stage. Steps without acursorbelong to the first.chaptersRequiredType
AutopilotChapter[]The script, as
{ title, setup?, steps }.setupputs the UI in the state the chapter starts from. It runs every time the chapter starts, so after a takeover, a jump or a loop the chapter replays from a known state.childrenRequiredType
React.ReactNodeThe UI the cursors use. Mark targets with
data-autopilot="<name>". While a cursor is on one it getsdata-autopilot-hover,-press,-focus,-dragor-drop, so style them next tohover:,active:andfocus:.chapterStripType
booleanDefaultfalseShow the chapters under the stage. The current one fills like a story timer, and clicking one jumps to it. The pause button moves into the strip.
loopType
booleanDefaulttrueStart over after the last chapter: the finished state holds for 2s, the stage crossfades back and the cursors glide home. With
falseit stops on the finished state and shows Play.parkType
"bottom-right" | "bottom-left" | "top-right" | "top-left"Default"bottom-right"The corner the cursors step aside to while the visitor has control, and wait in unless they have a
home. Pick one your UI keeps clear.yieldLabelType
stringDefault"Your turn"The parked cursor's label while the visitor has control.
onChapterChangeType
(index: number) => voidCalled as each chapter starts.
stageClassNameType
stringClasses for the stage, the box the cursors move in. Borders, rounding and
overflow-hiddengo here.classNameType
stringClasses for the outer wrapper, which also holds the chapter strip. The strip draws in the text color, so set
text-*here to recolor it.step.moveType
stringAutopilotStep: glide to the target with this name and rest over it.step.clickType
stringAutopilotStep: go to the target, wait a beat, then press and release. Pointer, mouse and click events fire on it with real coordinates, so the UI's own handlers run.step.type / step.intoType
stringAutopilotStep: click into the input or textarea named byinto(or the one inside it) and type the text at a person's pace, soonChangeruns per key.{Enter}and{Backspace}press those keys. Real focus never moves; the field getsdata-autopilot-focus.step.drag / step.toType
stringAutopilotStep: lift the target, carry it onto thetotarget and set it down. The item moves withtranslateandscalewhile it's held.step.dropType
() => voidAutopilotStep: withdrag, called as the item is set down. Move it in your state here; it settles from the drop point into its new place.step.waitType
numberAutopilotStep: rest for this many ms.step.doType
() => void | Promise<void>AutopilotStep: run a callback for state the demo owns. A returned promise is awaited.step.cursorType
stringAutopilotStep: the cursor that takes the step. Each cursor takes its own steps in order, and different cursors' steps run at the same time.step.sayType
string | nullAutopilotStep: change the cursor's label as the step starts. It stays until the nextsay;nullgoes back to the cursor'slabel.step.atType
[number, number]AutopilotStep: where on the target to point, as fractions of its width and height. By default it lands near the middle, never dead center, and toward the start of a text field.