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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stop-generating.jsonPrompt
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
scopeRefat, 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
appearAfterdecides 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.
AI Badge
A provenance chip for AI-touched content — generated / assisted / human-edited, with the model and timestamp behind a tooltip or popover and a contrast-safe overlay mode for images.
Regenerate Menu
A split retry control for one assistant answer — the button half re-runs it, the chevron opens a menu with a model submenu that marks the current model and an inline instruction field, and every path emits one discriminated action.