AI Patterns

onboarding

Prompt-Driven Onboarding

A setup screen that hands users a pre-filled prompt to paste into their AI coding agent, with a snippet fallback and a live listener that confirms the first event.

Add AI Patterns to your app

AI promptRecommended

Paste this prompt into an AI coding tool like Claude Code or Cursor — it picks a pattern and builds it in your design system.

Add one pattern from the ai-patterns catalogue to this app: a real screen, built in this codebase's own design system, that I can open and use.

1. Read the catalogue index at https://github.com/carlosmarch/ai-patterns/tree/main/plugins/ai-patterns/skills/ai-patterns/reference/index.md and shortlist patterns that fit a screen this app already has or needs (e.g. a composer, a pending-response loader, a tool approval prompt).
2. Detect the stack and design system: framework, styling (Tailwind, CSS modules, tokens), and component library. Note what's already installed.
3. Pick one pattern, open its spec (<category>/<slug>.md in the same folder) and follow it: Anatomy, Behavior, Content guidelines, Accessibility.
4. Build it into the existing components folder. If the app has its own design system, re-express the reference implementation with its components and tokens; only use the demoed Tailwind + Motion code verbatim if there's none. Add only the dependencies the pattern needs (e.g. motion, lucide-react).
5. Render it on a real route, run the app, and summarize the files you changed and where to see the pattern.
or set up another way
Waiting for your first pattern…
Copy the prompt or open it in a tool to simulate setup
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, Copy, ArrowRight, type LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

export interface CodingTool {
  id: string;
  label: string;
  icon?: LucideIcon;
}

export interface SetupAlternative {
  id: string;
  title: string;
  description: string;
  icon: LucideIcon;
  onClick?: () => void;
}

export type PromptOnboardingStatus = "waiting" | "received";

export interface PromptOnboardingProps {
  title: string;
  /** Shown masked in the header with a copy control. The prompt should already embed it. */
  apiKey?: string;
  /** The setup prompt the user hands to their coding agent. */
  prompt: string;
  /** Manual fallback: the classic SDK snippet or install commands. */
  snippet: string;
  /** One-line explainers under the method toggle. */
  hints?: Record<Mode, string>;
  /** Agents the prompt can be opened in. The first one is the split button's default. */
  tools: CodingTool[];
  alternatives?: SetupAlternative[];
  /** "waiting" until the product sees the first real signal (event, request, webhook). */
  status: PromptOnboardingStatus;
  statusLabels?: Record<PromptOnboardingStatus, string>;
  onOpenIn?: (tool: CodingTool) => void;
  onCopy?: (mode: "prompt" | "snippet") => void;
  /** Omit to hide the Skip link. */
  onSkip?: () => void;
  /** Omit to hide the Finish button, e.g. when the flow has no next step. */
  onFinish?: () => void;
  className?: string;
}

type Mode = "prompt" | "snippet";

const DEFAULT_HINTS: Record<Mode, string> = {
  prompt: "Paste this prompt into an AI coding tool — it reads your codebase and wires up the integration.",
  snippet: "Prefer to do it by hand? Install the SDK and add this where your app starts.",
};

const DEFAULT_STATUS_LABELS: Record<PromptOnboardingStatus, string> = {
  waiting: "Waiting for your events…",
  received: "First event received",
};

export function PromptOnboarding({
  title,
  apiKey,
  prompt,
  snippet,
  tools,
  alternatives = [],
  status,
  hints = DEFAULT_HINTS,
  statusLabels = DEFAULT_STATUS_LABELS,
  onOpenIn,
  onCopy,
  onSkip,
  onFinish,
  className,
}: PromptOnboardingProps) {
  const [mode, setMode] = React.useState<Mode>("prompt");
  const [expanded, setExpanded] = React.useState(false);
  const [copied, setCopied] = useCopied();
  const [toolId, setToolId] = React.useState(tools[0]?.id);
  const tool = tools.find((t) => t.id === toolId) ?? tools[0];
  const body = mode === "prompt" ? prompt : snippet;
  const received = status === "received";

  async function copyBody() {
    await writeClipboard(body);
    setCopied(mode);
    onCopy?.(mode);
  }

  return (
    <div className={cn("w-full overflow-hidden rounded-2xl border bg-card", className)}>
      <div className="flex flex-wrap items-center justify-between gap-3 px-5 pt-5">
        <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
        {apiKey && <ApiKeyChip apiKey={apiKey} />}
      </div>

      <div className="px-5 pt-4">
        <div className="relative rounded-xl border border-primary/30 p-4 shadow-[0_0_0_4px] shadow-primary/5">
          <div className="flex items-center gap-2">
            <span className="text-sm font-medium">{mode === "prompt" ? "AI prompt" : "Snippet"}</span>
            {mode === "prompt" && (
              <span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
                Recommended
              </span>
            )}
          </div>

          <div role="tablist" aria-label="Setup method" className="mt-3 inline-flex rounded-lg border p-0.5">
            {(["prompt", "snippet"] as const).map((m) => (
              <button
                key={m}
                type="button"
                role="tab"
                aria-selected={mode === m}
                onClick={() => setMode(m)}
                className={cn(
                  "relative rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
                  mode === m ? "text-foreground" : "text-muted-foreground hover:text-foreground"
                )}
              >
                {mode === m && (
                  <motion.span
                    layoutId="prompt-onboarding-tab"
                    className="absolute inset-0 rounded-md bg-accent"
                    transition={{ type: "spring", stiffness: 500, damping: 36 }}
                  />
                )}
                <span className="relative">{m === "prompt" ? "AI prompt" : "Snippet"}</span>
              </button>
            ))}
          </div>

          <p className="mt-2 text-xs text-muted-foreground">{hints[mode]}</p>

          <div role="tabpanel" className="relative mt-3 overflow-hidden rounded-lg bg-muted/60">
            <motion.pre
              key={mode}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1, height: expanded ? "auto" : 72 }}
              transition={{ duration: 0.25, ease: "easeOut" }}
              className="overflow-hidden whitespace-pre-wrap px-3 pt-2.5 pb-6 font-mono text-[11px] leading-relaxed text-foreground/90"
            >
              {body}
            </motion.pre>
            {!expanded && (
              <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-muted to-transparent" />
            )}
            <button
              type="button"
              onClick={() => setExpanded((e) => !e)}
              aria-expanded={expanded}
              className="absolute inset-x-0 bottom-0 flex justify-center py-1 text-muted-foreground hover:text-foreground"
            >
              <span className="sr-only">{expanded ? "Collapse" : "Show full"} {mode}</span>
              <ChevronDown className={cn("size-3.5 transition-transform", expanded && "rotate-180")} aria-hidden />
            </button>
          </div>

          <div className="mt-3 flex flex-wrap items-center justify-end gap-2">
            {mode === "prompt" && tool && (
              <OpenInButton tools={tools} tool={tool} onSelect={setToolId} onOpen={() => onOpenIn?.(tool)} />
            )}
            <button
              type="button"
              onClick={copyBody}
              className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-transform active:scale-[0.97]"
            >
              <SwapIcon done={copied === mode} />
              {copied === mode ? "Copied" : mode === "prompt" ? "Copy prompt" : "Copy snippet"}
            </button>
          </div>
        </div>
      </div>

      {alternatives.length > 0 && (
        <div className="px-5 pt-5">
          <div className="flex items-center gap-3 text-[11px] text-muted-foreground">
            <span className="h-px flex-1 bg-border" />
            or set up another way
            <span className="h-px flex-1 bg-border" />
          </div>
          <div className="mt-3 grid gap-2 sm:grid-cols-2">
            {alternatives.map((alt) => (
              <button
                key={alt.id}
                type="button"
                onClick={alt.onClick}
                className="flex items-center gap-3 rounded-xl border p-3 text-left transition-colors hover:bg-accent/50"
              >
                <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
                  <alt.icon className="size-4" aria-hidden />
                </span>
                <span className="min-w-0">
                  <span className="block text-xs font-medium">{alt.title}</span>
                  <span className="block truncate text-[11px] text-muted-foreground">{alt.description}</span>
                </span>
              </button>
            ))}
          </div>
        </div>
      )}

      <div className="mt-5 flex flex-col items-center gap-2 border-t bg-muted/40 px-5 py-3">
        <div role="status" aria-live="polite" className="flex items-center gap-2 text-xs font-medium">
          <StatusDot received={received} />
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={status}
              initial={{ opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -4 }}
              transition={{ duration: 0.18 }}
            >
              {statusLabels[status]}
            </motion.span>
          </AnimatePresence>
        </div>
        {(onSkip || onFinish) && (
          <div className="flex items-center gap-3">
            {onSkip && !received && (
              <button
                type="button"
                onClick={onSkip}
                className="text-xs font-medium text-primary hover:underline"
              >
                Skip for now
              </button>
            )}
            {onFinish && (
              <button
                type="button"
                onClick={onFinish}
                disabled={!received}
                className={cn(
                  "inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium transition-colors",
                  received
                    ? "bg-primary text-primary-foreground"
                    : "cursor-not-allowed bg-muted text-muted-foreground/70"
                )}
              >
                Finish setup
                <ArrowRight className="size-3" aria-hidden />
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

function OpenInButton({
  tools,
  tool,
  onSelect,
  onOpen,
}: {
  tools: CodingTool[];
  tool: CodingTool;
  onSelect: (id: string) => void;
  onOpen: () => void;
}) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (!open) return;
    function onPointer(e: PointerEvent) {
      if (!ref.current?.contains(e.target as Node)) setOpen(false);
    }
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }
    document.addEventListener("pointerdown", onPointer);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointer);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const Icon = tool.icon;

  return (
    <div ref={ref} className="relative inline-flex h-8 rounded-lg border text-xs font-medium">
      <button
        type="button"
        onClick={onOpen}
        className="inline-flex items-center gap-1.5 rounded-l-lg px-3 transition-colors hover:bg-accent/60"
      >
        {Icon && <Icon className="size-3.5" aria-hidden />}
        Open in {tool.label}
      </button>
      {tools.length > 1 && (
        <button
          type="button"
          onClick={() => setOpen((o) => !o)}
          aria-haspopup="menu"
          aria-expanded={open}
          aria-label="Choose coding tool"
          className="inline-flex items-center rounded-r-lg border-l px-2 transition-colors hover:bg-accent/60"
        >
          <ChevronDown className={cn("size-3.5 transition-transform", open && "rotate-180")} aria-hidden />
        </button>
      )}
      <AnimatePresence>
        {open && (
          <motion.div
            role="menu"
            initial={{ opacity: 0, y: 4, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.98 }}
            transition={{ duration: 0.14 }}
            className="absolute right-0 bottom-full z-10 mb-1.5 min-w-44 origin-bottom-right rounded-lg border bg-popover p-1 shadow-lg"
          >
            {tools.map((t) => {
              const ItemIcon = t.icon;
              return (
                <button
                  key={t.id}
                  type="button"
                  role="menuitemradio"
                  aria-checked={t.id === tool.id}
                  onClick={() => {
                    onSelect(t.id);
                    setOpen(false);
                  }}
                  className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent"
                >
                  {ItemIcon && <ItemIcon className="size-3.5 text-muted-foreground" aria-hidden />}
                  <span className="flex-1">{t.label}</span>
                  {t.id === tool.id && <Check className="size-3.5" aria-hidden />}
                </button>
              );
            })}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function ApiKeyChip({ apiKey }: { apiKey: string }) {
  const [copied, setCopied] = useCopied();
  const masked = `${apiKey.slice(0, 4)}${"•".repeat(10)}${apiKey.slice(-4)}`;

  return (
    <div className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs">
      <span className="text-muted-foreground">API key</span>
      <code className="font-mono text-[11px]">{masked}</code>
      <button
        type="button"
        onClick={async () => {
          await writeClipboard(apiKey);
          setCopied("key");
        }}
        aria-label={copied ? "API key copied" : "Copy API key"}
        className="text-muted-foreground hover:text-foreground"
      >
        <SwapIcon done={copied === "key"} />
      </button>
    </div>
  );
}

function SwapIcon({ done }: { done: boolean }) {
  return (
    <span className="relative inline-flex size-3.5">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={done ? "check" : "copy"}
          initial={{ scale: 0.5, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          exit={{ scale: 0.5, opacity: 0 }}
          transition={{ duration: 0.15 }}
          className="absolute inset-0"
        >
          {done ? <Check className="size-3.5" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function StatusDot({ received }: { received: boolean }) {
  return (
    <span className="relative flex size-2.5" aria-hidden>
      {!received && (
        <motion.span
          className="absolute inset-0 rounded-full bg-primary"
          animate={{ scale: [1, 2.2], opacity: [0.5, 0] }}
          transition={{ duration: 1.4, repeat: Infinity, ease: "easeOut" }}
        />
      )}
      <motion.span
        className={cn("relative size-2.5 rounded-full", received ? "bg-emerald-500" : "bg-primary")}
        animate={received ? { scale: [0.6, 1.2, 1] } : { scale: 1 }}
        transition={{ duration: 0.35 }}
      />
    </span>
  );
}

function useCopied() {
  const [copied, setCopied] = React.useState<string | null>(null);
  React.useEffect(() => {
    if (!copied) return;
    const id = setTimeout(() => setCopied(null), 1600);
    return () => clearTimeout(id);
  }, [copied]);
  return [copied, setCopied] as const;
}

async function writeClipboard(text: string) {
  try {
    await navigator.clipboard.writeText(text);
  } catch {
    // Clipboard can be blocked (insecure context, iframe); the UI still confirms.
  }
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Prompt-Driven Onboarding

## Summary
A first-run setup screen that replaces "read the docs, find the SDK, copy the code, figure out where it goes" with a single, pre-filled prompt the user pastes into their AI coding agent (Cursor, Claude Code, etc.). The agent reads the user's actual codebase and performs the integration. The product then listens for the first real signal — an event, request, or webhook — and confirms setup is complete. The shift is from "here's how to set up our product" to "here's a prompt that sets up our product for you."

## When to use
- Developer tools, APIs, SDKs, analytics, monitoring, auth, payments — any SaaS whose activation step is "add code to your app".
- Setup that varies by framework or project structure, where a static snippet forces the user to translate it to their stack.
- Products that can detect success on their side (first event ingested, first API call, first webhook delivered), so the screen can close the loop.

## When not to use
- No-code products or setups done entirely in your own UI — there's no codebase for an agent to work in.
- Integrations that are a single, universal line (a script tag with no options) — a snippet is faster than a prompt.
- Audiences unlikely to use AI coding tools, or regulated environments where agents can't touch the codebase. Keep the snippet path, and consider making it the default there.
- Don't drop the manual path entirely: the prompt is the recommended route, not the only one.

## Anatomy
- **Header**: outcome-oriented title ("Let's get data in") and a masked **API key chip** with copy.
- **Recommended method card**: visually emphasized (accent border/glow) with a "Recommended" badge.
  - **Method toggle**: "AI prompt" / "Snippet" segmented control — the classic code path stays one click away.
  - **One-line explainer**: what to do with the prompt and what it will do.
  - **Prompt preview**: monospace block, collapsed to the first lines with a fade and an expand control.
  - **Actions**: an "Open in <tool>" split button (default tool + menu of other agents) and a primary **Copy prompt** button.
- **Alternatives**: divider ("or set up another way") followed by cards for non-code routes — integrations/import, or inviting a teammate to do it.
- **Status footer**: live listening indicator ("Waiting for your events…"), a **Skip for now** link, and a **Finish setup** button that stays disabled until the first signal arrives. Both are optional — drop them when the card stands alone with no next step (e.g. a docs page), and let the status line carry the confirmation.

## Behavior
- The prompt is generated per account/project: it embeds the API key, project/environment name, and any choices made earlier in onboarding. It should never be a generic doc excerpt.
- The prompt states the goal and the definition of done first ("one real, app-produced first event, ingested into project X"), then concrete steps: detect the stack, install, initialize once at the entry point, track a meaningful event, run it, and summarize changes.
- **Copy prompt** writes to the clipboard and swaps its icon/label to "Copied" for ~1.5s.
- **Open in <tool>** deep-links into the selected agent with the prompt prefilled when the tool supports it; otherwise copy and show a short "paste it into <tool>" hint. The menu remembers the last-chosen tool.
- Switching to **Snippet** swaps the preview and copy action in place; the "Open in" button hides since there's nothing for an agent to run.
- The footer listens continuously (polling or a live connection). When the first signal lands, the pulsing dot settles into a success state, the label changes ("First event received"), and **Finish setup** enables. Don't auto-advance — let the user see the confirmation.
- **Skip for now** exits without penalty; re-surface the step later (e.g. via a setup checklist).

## Content guidelines
- Title names the outcome, not the task: "Let's get data in", "Send your first request" — not "Install the SDK".
- Explainer: one sentence, name example tools ("an AI coding tool like Cursor or Claude Code").
- Prompt: plain imperative language, numbered steps, explicit success criterion. Instruct the agent to keep secrets in env vars rather than hard-coding keys in client code, and to report what it changed so the user can review the diff.
- Keep the preview short when collapsed; the user should grasp the goal from the first line.
- Status labels are specific to the signal: "Waiting for your events…", "Waiting for your first API call…".

## Accessibility
- The method toggle uses `role="tablist"`/`role="tab"` with `aria-selected`; the preview is the `tabpanel`.
- The expand control has `aria-expanded` and an accessible name ("Show full prompt").
- The tool menu trigger has `aria-haspopup="menu"` and `aria-expanded`; items are `menuitemradio` with `aria-checked`; Escape and outside click close it.
- The status line is an `aria-live="polite"` region so the "received" transition is announced.
- The disabled Finish button must still be discoverable — keep the status text adjacent explaining what it's waiting for.
- Respect `prefers-reduced-motion` for the pulsing dot and icon swaps.
- The API key is masked visually; the copy button's label announces success ("API key copied").

## Related patterns
- Setup Checklist — re-surfaces this step if the user skips it.
- Terminal Stream / Background Tasks Drawer — if the product runs its own agent instead of handing the prompt off.
- Tool Approval — what the user will see inside their coding agent as it applies the setup.