{"slug":"agent-setup-pill","category":"onboarding","job":"start","added":"2026-11-06T21:56Z","title":"Agent Setup Pill","description":"A floating pill that copies a setup prompt for the user's AI coding agent in one click — outcome label, a row of supported agents, a tooltip that says what the click does, and a separate dismiss button.","url":"https://ai-patterns.app/patterns/onboarding/agent-setup-pill","markdown":"https://ai-patterns.app/patterns/onboarding/agent-setup-pill.md","json":"https://ai-patterns.app/api/patterns/onboarding/agent-setup-pill","seenIn":[{"team":"cloudflare","source":"https://developers.cloudflare.com/"}],"spec":"# Agent Setup Pill\n\n## Summary\nA small floating pill that hands setup off to the user's AI coding agent in one click. The label names the outcome (\"Onboard your agent to Acme\"), a row of agent marks shows which tools it works with, a tooltip says what the click will do, and clicking copies a ready-made setup prompt to the clipboard. A separate round button dismisses it.\n\n## When to use\n- Docs sites, dashboards and quickstarts where many users will set the product up through Claude Code, Cursor, Copilot or similar instead of by hand.\n- As a lightweight, always-reachable entry point that sits alongside the regular docs — not instead of them.\n- When the setup can be described as one self-contained prompt (install, configure, verify).\n\n## When not to use\n- Inside a guided onboarding step that needs to wait for a first event — use the Prompt Onboarding card, which shows the prompt and tracks status.\n- When the setup needs account-specific secrets the page can't safely put in a prompt on a public page.\n- As a general \"Ask AI\" entry point — this pill only copies a setup prompt; it doesn't open a chat.\n\n## Anatomy\n- Pill button: outcome label + a row of 3–5 agent marks.\n- Tooltip above the pill: one line describing the action (\"Copies a setup prompt for your AI coding tool\").\n- Copied state: the label swaps to a check and a short instruction; the marks collapse.\n- Dismiss button: a separate circular button with an ×, visually detached from the pill so it can't be hit by accident.\n\n## Behavior\n- Enters with a short rise-and-fade once the page settles; agent marks stagger in after the label.\n- Hover or keyboard focus shows the tooltip; Escape, blur and pointer leave hide it. The tooltip never appears in the copied state.\n- On hover, the agent marks fan out slightly as an affordance that the pill is clickable.\n- Click copies the prompt. The label rolls to \"Prompt copied — paste it into your agent\" with a check for ~2s, then returns. The pill resizes smoothly between the two widths.\n- If the clipboard API is blocked, still confirm, and make sure the prompt is reachable elsewhere (e.g. a docs page) so the user isn't stuck.\n- Dismiss hides the pill with a short exit animation and should persist (per user or per browser) — don't bring it back on the next page view.\n\n## Content guidelines\n- Label: outcome-first and product-specific — \"Onboard your agent to Acme\", \"Set up Acme with your agent\". Not \"Copy prompt\".\n- Tooltip: one sentence, says exactly what happens — the click copies, it doesn't open anything.\n- Prompt: imperative, numbered steps, an explicit definition of done, keep secrets in env vars, and ask the agent to summarize its changes.\n- Agent marks: only tools the prompt has actually been tested with; keep the brand marks small and monochrome except where the brand colour is the mark.\n\n## Accessibility\n- The pill is a real `<button>`; its accessible name is the visible label. The agent marks are `aria-hidden` decoration (each still has a `title` for sighted pointer users).\n- The tooltip uses `role=\"tooltip\"` and is linked with `aria-describedby` while visible; it opens on focus as well as hover.\n- The copied confirmation is announced through an `aria-live=\"polite\"` region.\n- The dismiss button has `aria-label=\"Dismiss\"` and a 40px hit target.\n- Respect `prefers-reduced-motion`: drop the rise, roll and fan-out; keep plain fades.\n\n## Related patterns\n- **Prompt-Driven Onboarding** — Use it instead when there is room for the full card with a prompt preview, snippet fallback and first-event status.\n- **Setup Checklist** — Use it as well so a dismissed pill's step can live on as a checklist item.\n- **Shiny Button** — Use it instead for a different single call to action, and don't show both on one screen.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface SetupAgent {\n  id: string;\n  label: string;\n  /** A small mark (~16px). Brand marks read best as monochrome or their own colours. */\n  icon: React.ReactNode;\n}\n\nexport interface AgentSetupPillProps {\n  /** Outcome-first label, e.g. \"Onboard your agent to Acme\". */\n  label: string;\n  /** Says what the click does before it happens. */\n  tooltip?: string;\n  /** The setup prompt copied to the clipboard. */\n  prompt: string;\n  /** The coding agents the prompt works in, shown as a row of marks. */\n  agents: SetupAgent[];\n  /** Shown in place of the label for a moment after copying. */\n  copiedLabel?: string;\n  onCopy?: () => void;\n  /** Omit to hide the dismiss button. */\n  onDismiss?: () => void;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 32 } as const;\n\nexport function AgentSetupPill({\n  label,\n  tooltip = \"Copies a setup prompt for your AI coding tool\",\n  prompt,\n  agents,\n  copiedLabel = \"Prompt copied — paste it into your agent\",\n  onCopy,\n  onDismiss,\n  className,\n}: AgentSetupPillProps) {\n  const reduce = useReducedMotion();\n  const tooltipId = React.useId();\n  const [copied, setCopied] = React.useState(false);\n  const [hint, setHint] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(prompt);\n    } catch {\n      // Clipboard can be blocked (insecure context, iframe); still confirm.\n    }\n    setCopied(true);\n    setHint(false);\n    onCopy?.();\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setCopied(false), 2200);\n  }\n\n  return (\n    <motion.div\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96 }}\n      transition={spring}\n      className={cn(\"flex items-center gap-2\", className)}\n    >\n      <div className=\"relative\">\n        <AnimatePresence>\n          {hint && !copied && (\n            <motion.div\n              id={tooltipId}\n              role=\"tooltip\"\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 4 }}\n              transition={{ duration: 0.15 }}\n              className=\"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-lg border bg-popover px-3 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-sm\"\n            >\n              {tooltip}\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <motion.button\n          type=\"button\"\n          onClick={copy}\n          onPointerEnter={() => setHint(true)}\n          onPointerLeave={() => setHint(false)}\n          onFocus={() => setHint(true)}\n          onBlur={() => setHint(false)}\n          onKeyDown={(e) => e.key === \"Escape\" && setHint(false)}\n          aria-describedby={hint ? tooltipId : undefined}\n          whileTap={reduce ? undefined : { scale: 0.97 }}\n          layout={!reduce}\n          transition={spring}\n          className=\"group flex h-10 items-center gap-3 rounded-full border bg-card pr-3 pl-4 text-sm font-medium text-card-foreground shadow-sm transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n        >\n          <span className=\"relative grid overflow-hidden\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={copied ? \"copied\" : \"idle\"}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -12 }}\n                transition={{ duration: 0.2 }}\n                className=\"flex items-center gap-1.5 whitespace-nowrap\"\n              >\n                {copied && <Check className=\"size-4 text-emerald-600 dark:text-emerald-400\" aria-hidden />}\n                {copied ? copiedLabel : label}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n\n          <AnimatePresence initial={false}>\n            {!copied && (\n              <motion.span\n                aria-hidden\n                initial={{ opacity: 0, width: 0 }}\n                animate={{ opacity: 1, width: \"auto\" }}\n                exit={{ opacity: 0, width: 0 }}\n                transition={{ duration: 0.2 }}\n                className=\"flex items-center\"\n              >\n                {agents.map((agent, i) => (\n                  <motion.span\n                    key={agent.id}\n                    title={agent.label}\n                    initial={reduce ? false : { opacity: 0, y: 6 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    transition={{ ...spring, delay: reduce ? 0 : 0.15 + i * 0.05 }}\n                    className=\"grid size-5 place-items-center transition-[margin,transform] duration-200 not-first:ml-0.5 group-hover:not-first:ml-1.5 group-hover:-translate-y-px [&_svg]:size-4\"\n                  >\n                    {agent.icon}\n                  </motion.span>\n                ))}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </motion.button>\n\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {copied ? copiedLabel : \"\"}\n        </span>\n      </div>\n\n      {onDismiss && (\n        <motion.button\n          type=\"button\"\n          onClick={onDismiss}\n          aria-label=\"Dismiss\"\n          whileTap={reduce ? undefined : { scale: 0.92 }}\n          className=\"grid size-10 shrink-0 place-items-center rounded-full border bg-card text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n        >\n          <X className=\"size-4\" />\n        </motion.button>\n      )}\n    </motion.div>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}