{"slug":"stop-generation-button","category":"buttons","job":"oversee","added":"2026-09-12T06:58Z","title":"Stop Generation Button","description":"A send button that morphs into a stop control mid-stream and back on completion.","url":"https://ai-patterns.app/patterns/buttons/stop-generation-button","markdown":"https://ai-patterns.app/patterns/buttons/stop-generation-button.md","json":"https://ai-patterns.app/api/patterns/buttons/stop-generation-button","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/7996703-troubleshooting-chatgpt-error-messages"},{"team":"Google Gemini","source":"https://support.google.com/googlebook/answer/18429222"}],"spec":"# Stop Generation Button\n\n## Summary\nA composer's send control that morphs into a Stop button the instant generation begins, then morphs back the instant it ends — the same control across all three states (idle, generating, done) so the user's hand never has to find a different button to interrupt a response.\n\n## When to use\n- Any composer whose output streams over a real window of time (an LLM response, a long-running job) where the user may want to interrupt it mid-flight.\n- As the one control that owns both \"start\" and \"stop\" for a single turn — never a second, separately-positioned cancel button.\n\n## When not to use\n- For actions that can't actually be interrupted — never show a Stop control that doesn't stop anything real; that's worse than no control at all.\n- For near-instant responses with no meaningful in-flight window — the morph would flash and add noise instead of giving the user a real chance to act.\n\n## Anatomy\n- A single circular (or pill) icon button.\n- Idle state: a send/arrow icon, disabled when the input is empty.\n- Generating state: a solid stop-square icon, always enabled.\n- The shape morphs between states with motion — not an abrupt icon swap — so it reads as the same control changing purpose, not a different button appearing.\n\n## Behavior\n- Clicking while idle (with input present) submits immediately and the button morphs to Stop before the first token of the response arrives — the transition is optimistic, not waiting on a server round-trip.\n- Clicking while generating cancels the stream and the button morphs back to idle immediately, without an intermediate loading state on the click itself.\n- Disabled only in the idle state with empty input; always interactive while generating, since stopping should never be blocked.\n- The morph plays every transition, including generating → idle on natural completion, so a finished response and a user-cancelled one both land back on the same recognizable send affordance.\n\n## Content guidelines\n- Icon-only is standard; if a label is shown alongside, it swaps in lockstep with the icon (\"Send\" / \"Stop\") rather than staying static.\n\n## Accessibility\n- `aria-label` updates with state (\"Send message\" / \"Stop generating\") so assistive tech announces the button's current purpose, not just its icon.\n- Remains a real, focusable, keyboard-operable button (Enter/Space) in every state.\n- Disabled state uses the `disabled` attribute, not opacity alone, so it's programmatically detectable.\n\n## Related patterns\n- **Thinking Loader** — Use it as well as the passive still-working signal, while this button lets people act on that same wait.\n- **Partial Response** — Use it as well after stopping, to show the cut-short answer with an interrupted reason and a way to continue.\n- **Message Queue** — Use it as well when people should be able to queue their next message instead of stopping the current one.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { ArrowUp, Square } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type GenerationState = \"idle\" | \"generating\";\n\nexport interface StopGenerationButtonProps {\n  state: GenerationState;\n  /** Disables the idle/send state (e.g. empty input). Ignored while generating — stopping is never blocked. */\n  disabled?: boolean;\n  onSubmit?: () => void;\n  onStop?: () => void;\n  className?: string;\n}\n\nexport function StopGenerationButton({\n  state,\n  disabled = false,\n  onSubmit,\n  onStop,\n  className,\n}: StopGenerationButtonProps) {\n  const generating = state === \"generating\";\n\n  return (\n    <motion.button\n      type=\"button\"\n      layout\n      disabled={!generating && disabled}\n      onClick={generating ? onStop : onSubmit}\n      aria-label={generating ? \"Stop generating\" : \"Send message\"}\n      whileTap={{ scale: 0.92 }}\n      transition={{ layout: { duration: 0.18, ease: \"easeOut\" } }}\n      className={cn(\n        \"flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background transition-colors disabled:bg-muted disabled:text-muted-foreground\",\n        className\n      )}\n    >\n      <motion.span\n        key={generating ? \"stop\" : \"send\"}\n        initial={{ opacity: 0, scale: 0.6 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={{ duration: 0.15 }}\n        className=\"flex items-center justify-center\"\n      >\n        {generating ? <Square className=\"size-3 fill-current\" /> : <ArrowUp className=\"size-4\" />}\n      </motion.span>\n    </motion.button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}