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.
@keyframeui
registry/demos/autopilot-multiplayer-demo.tsx
"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>
);
}
Live preview, examples and props: https://keyframeui.com/components/autopilot#autopilot-multiplayer-demo














