# Autopilot: 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/autopilot-multiplayer-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/autopilot-multiplayer-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/autopilot-multiplayer-demo-light-97c266c0c63ed3ef.png
- Registry item: https://keyframeui.com/r/autopilot-multiplayer-demo.json
- Categories: cursor, interaction, showcase
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/autopilot-multiplayer-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/autopilot.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/autopilot#autopilot-multiplayer-demo

## Files

### `registry/demos/autopilot-multiplayer-demo.tsx`

```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>
  );
}
```
