AI

Stop Generating

One button slot for the whole run — Stop while tokens arrive, Continue generating when the reply hit its length cap, nothing when idle, with a scoped Esc binding and a one-shot lock on both actions.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronsRight, Loader2, Square } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Pulse ring
 *
 * Keyframes ship with the component through a React 19 hoisted <style> — no
 * Tailwind config edit, and duplicate instances dedupe by href.
 *
 * The ring is a SIBLING span, not a box-shadow on the button: an animated
 * shadow is repainted every frame together with the button's own hover/focus

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stop-generating.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "StopGenerating" component (lucide-react
for icons). It is ONE button slot that renders the whole life of a generation:
abort it, continue a reply that was cut off, or disappear.

Contract
- forwardRef<HTMLButtonElement>, extends React.ButtonHTMLAttributes minus
  children / onClick / type. Remaining props spread onto the <button>; the
  consumer's className is merged into the button, not the wrapper.
- state?: "idle" | "streaming" | "truncated" (default "idle"). The CALLER owns
  it; the component never flips it. "truncated" means the model stopped on its
  own at the length cap — the reply is over but not finished.
- onStop?: () => void | Promise<unknown> — abort the run.
- onContinue?: () => void | Promise<unknown> — ask for a continuation.
  Returning a promise puts the button in its busy phase until it settles.
- shortcut?: boolean (default true) — bind Esc while streaming.
- scopeRef?: RefObject<HTMLElement | null> — Esc only counts while focus is
  inside this element. Omitted = page-wide.
- returnFocusRef?: RefObject<HTMLElement | null> — where focus goes when the
  slot collapses while the button holds it (normally the composer).
- appearAfter?: number ms (default 0) — how long a run must last before the
  button appears.
- pulse?: boolean (default true), announce?: boolean (default true),
  hint?: "auto" | "always" | "never" (default "auto"),
  size?: "sm" | "default".
- Every string is a prop: stopLabel "Stop", stoppingLabel "Stopping…",
  continueLabel "Continue generating", continuingLabel "Continuing…",
  stoppedMessage, truncatedMessage, plus reason?: ReactNode rendered beside the
  Continue button and wired to it through aria-describedby.

Behavior — the one-shot lock (the reason this component exists)
- Each value of `state` is one RUN, and a run accepts exactly ONE action.
  Keep the guard in a ref, not in state: two clicks inside a single task (a
  double-click, a synthetic burst, Enter held down) both read the same rendered
  value of a state variable and both fire. A ref is written synchronously.
- Close the lock BEFORE invoking the handler, so a handler that re-enters
  synchronously (setState → re-render → focus → another click) still gets one shot.
- Internal phase: "armed" → "busy" (a thenable is in flight) → "spent". busy and
  spent both render aria-disabled + a spinner; only a NEW `state` re-arms.
- A rejected handler is NOT a spent one: re-open the lock, return to "armed",
  render the reason in text-destructive and announce it with " Try again."
- A synchronous (void) handler goes straight to "spent" — its work is done.
- Ignore settles from a superseded run: capture a run id when firing and drop
  the callback if the id moved or the component unmounted.
- Adjust all of this DURING RENDER on a `state` change (the prevProps pattern),
  never in an effect: the swapped label must never be painted in the previous
  run's spent state.

Behavior — the shortcut
- Bind one document keydown listener, only while state === "streaming", and
  remove it on cleanup. Esc is deliberately NOT bound in "truncated": continuing
  is a new request and nobody expects Escape to start one.
- Ignore the press when: event.defaultPrevented (a menu/popover already consumed
  it), event.repeat, event.isComposing || keyCode === 229 (Escape dismisses the
  IME candidate window — that press belongs to the editor), any of
  alt/ctrl/meta/shift (chorded Escape belongs to the OS), scopeRef is set and
  does not contain document.activeElement, or a `[aria-modal="true"], dialog[open]`
  that does not contain the button is on screen (a modal owns Escape).
- Otherwise preventDefault and fire the same code path as the click. Read the
  trigger through a ref so the listener is bound once per run, not per render.

Behavior — the slot
- Keep ONE <button> element across streaming → truncated; only the label, the
  icon and the handler swap. Remounting would drop focus of the keyboard user
  who was standing on it.
- state="idle" renders no button, but the wrapper span STAYS mounted: it carries
  the live region, and a region unmounted in the same commit as the transition
  never announces it.
- appearAfter: schedule a timer when streaming starts and only reveal the button
  when it fires, so a reply that ends in 200 ms never flashes a Stop button. The
  shortcut is bound immediately either way. Clear the timer on unmount / state
  change. "truncated" ignores the delay — that run is already over.
- returnFocus: track focus on the button; when the slot collapses while it held
  focus and the browser dropped focus to <body>, move it to returnFocusRef.
  If anything else took focus in the same tick, leave it alone.

Behavior — announcements
- One sr-only role="status" aria-live="polite" span. Announce TRANSITIONS only,
  never the mounted state: a component that mounts into a finished run is silent.
- streaming → "" ; truncated → truncatedMessage ; idle → stoppedMessage only if
  the user asked for the stop. A reply that simply finished says nothing.
- Firing an action announces stoppingLabel / continuingLabel immediately, so a
  screen-reader user knows the abort was accepted before the run actually ends.

Rendering & styling
- Semantic tokens only: bg-card, text-foreground, text-muted-foreground, border,
  ring-ring, hover:bg-muted, text-destructive. No hex, no rgb(), no oklch().
- Pill shell: rounded-full border bg-card, shadow-xs, focus-visible:ring-2
  ring-ring, aria-disabled:pointer-events-none aria-disabled:opacity-60. Never
  the native `disabled` attribute — it would move focus to <body> mid-run.
- Icons: a filled square (fill-current) for stop, a double chevron for continue,
  a spinner with animate-spin motion-reduce:animate-none while busy. All
  aria-hidden; the accessible name is the visible label.
- Pulse: two absolutely positioned sibling spans with ring-1 ring-ring, the
  second delayed by half the period, animating opacity + transform only (they
  stay on the compositor; an animated box-shadow on the button would not).
  Keyframes ship in a React 19 hoisted <style href precedence> so no Tailwind
  config edit is needed. They are hidden under motion-reduce AND skipped by a
  matchMedia("(prefers-reduced-motion: reduce)") subscription — the control
  keeps working, it just stops breathing.
- The Esc hint is a <kbd aria-hidden> next to the label, plus
  aria-keyshortcuts="Escape" on the button. hint="auto" subscribes to
  "(hover: hover) and (pointer: fine)" and hides the hint on touch, where the
  key does not exist. Both media subscriptions use useSyncExternalStore with a
  false server snapshot, so the first paint never shows a key a touch user
  cannot press.

Customization levers
- Shape: swap rounded-full for rounded-lg and the pill becomes a toolbar button;
  drop the label span for an icon-only control (keep an aria-label).
- Emphasis: the stop shell is deliberately quiet (bg-card) so it does not compete
  with Send. For a louder continue action give the truncated branch
  bg-primary text-primary-foreground and keep the stop branch as is.
- Motion: the pulse period (1.9s), the second ring's delay (950ms) and the scale
  target (1.45) are the whole animation. Drop the second ring for a calmer
  control, or pass pulse={false} and keep only the spinner.
- Timings: appearAfter 300–800ms is the useful anti-flash range; 0 disables it.
  The busy phase lasts exactly as long as your promise, so return the real abort
  round-trip instead of a fake delay.
- Keys: swap "Escape" for your own combination by changing the one match in the
  listener (and aria-keyshortcuts with it); pass shortcut={false} when the page
  already owns Esc.
- Copy / i18n: all six labels and both announcements are props; `reason` takes
  a node, so it can be a link to the token settings instead of plain text.
- Density: size="sm" for a composer toolbar, "default" beside a message. The
  wrapper's gap-2 is the only spacing between the button and the reason.

Concepts

  • One-shot per run — a run accepts exactly one action, and the guard lives in a ref because two clicks inside one task both read the same rendered state and would both abort; the lock closes before the handler is called, and only a new state (or a rejection) re-arms it.
  • Slot swap, not slot remount — streaming and truncated share the same <button> node, so a keyboard user standing on Stop is still standing on Continue generating when the length cap ends the reply; only the label, the icon and the handler change.
  • Scoped keybinding — Esc is bound only while a run is streaming, only inside the element you point scopeRef at, and never when the press was already consumed by a layer above, belongs to an IME candidate window, or belongs to a modal that is not the button's ancestor.
  • Abort round-trip — a handler that returns a promise buys a visible busy phase: the button says "Stopping…" and is inert until the server acknowledges, and a rejection gives the shot back with a readable reason instead of a dead control.
  • Delayed appearance — a Stop button that flashes for two frames on a 200 ms reply is noise, so appearAfter decides how long a run must live before it earns a control; the keyboard path is bound from the first token regardless.
  • Quiet transitions — the polite live region speaks only on state changes the user caused: the stop they asked for, the length cap they ran into. A reply that finished by itself is announced by the message, not by this control.

On This Page