Stop Generation Cut
Halting a stream folds the caret away, closes the partial answer with a hairline, and states that it stopped.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Stop Generation Cut
*
* The halt frame of a streaming answer: the caret stops blinking and
* collapses, a hairline rule closes the partial text, and a quiet
* "stopped" note takes the caret's place.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the block reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `text`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StopGenerationCutProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The answer being streamed. Split on whitespace — a word is one tick. */
text?: string;
/** Label of the halt control while the answer is still arriving. */
stopLabel?: string;
/** Label the control takes once the stream has ended. */
restartLabel?: string;
/** Note shown under a cut answer. */
stoppedNote?: string;
/** Caret color. */
accent?: string;
/** Fires when the reader halts the stream. */
onStop?: () => void;
};
type VariantConfig = {
/** ms between words — the perceived generation speed. */
cadenceMs: number;
/** Beat before the first word, so the caret is seen waiting first. */
leadInMs: number;
/** How fast the caret folds away once the stream is cut. */
caretCollapse: number;
/** Sweep of the hairline that closes the answer. */
ruleDuration: number;
/** px the note travels up as it arrives. */
noteRiseY: number;
noteSpring: { type: "spring"; stiffness: number; damping: number };
};
// The cut has to feel like a decision, not a crash: everything after the
// click is fast and monotonic — no bounce, no flash. Damping ratios
// (ζ = damping / 2√stiffness) sit at or above 0.85 so the note lands once.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.03 — the note simply appears. For dense chat threads where a
// stopped answer is a routine event.
subtle: {
cadenceMs: 66,
leadInMs: 240,
caretCollapse: 0.12,
ruleDuration: 0.22,
noteRiseY: 0,
noteSpring: { type: "spring", stiffness: 520, damping: 47 },
},
// ζ ≈ 0.93 — a short rise under the rule. The all-purpose setting.
default: {
cadenceMs: 54,
leadInMs: 280,
caretCollapse: 0.16,
ruleDuration: 0.3,
noteRiseY: 6,
noteSpring: { type: "spring", stiffness: 420, damping: 38 },
},
// ζ ≈ 0.87 — more travel and a quicker stream, for a single hero answer.
playful: {
cadenceMs: 40,
leadInMs: 220,
caretCollapse: 0.18,
ruleDuration: 0.36,
noteRiseY: 10,
noteSpring: { type: "spring", stiffness: 380, damping: 34 },
},
};
const SAMPLE_TEXT =
"Here is a first pass at the release note. The March update focuses on faster search, a redesigned billing page, and fixes for the duplicate invoice reports from last month.";
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` gives a surface, a border and a rule that are
* correctly toned in either theme. The caret accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function StopGenerationCut({
variant = "default",
text = SAMPLE_TEXT,
stopLabel = "Stop",
restartLabel = "Regenerate",
stoppedNote = "Stopped generating",
accent = "#7C7CF0",
onStop,
}: StopGenerationCutProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const words = text.trim().split(/\s+/);
const [revealed, setRevealed] = useState(0);
const [stopped, setStopped] = useState(false);
const finished = revealed >= words.length;
const streaming = !stopped && !finished;
// One timer per word, scheduled from the current count: nothing drifts,
// and unmounting mid-stream cleans itself up. The cadence is kept under
// reduced motion — words arriving is data landing, not decoration; only
// the fade, the rise and the blink are dropped below.
useEffect(() => {
if (!streaming) return;
const id = setTimeout(
() => setRevealed((n) => n + 1),
revealed === 0 ? cfg.leadInMs : cfg.cadenceMs
);
return () => clearTimeout(id);
}, [streaming, revealed, cfg.cadenceMs, cfg.leadInMs]);
const handleStop = () => {
if (streaming) {
setStopped(true);
onStop?.();
return;
}
setStopped(false);
setRevealed(0);
};
const wordTransition = reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: "easeOut" as const };
return (
<div
style={{
width: 300,
display: "flex",
flexDirection: "column",
gap: 12,
fontSize: 13.5,
}}
>
<p
style={{
margin: 0,
minHeight: 80,
lineHeight: 1.62,
// The paragraph is a run of inline spans, so the caret sits on
// the write head rather than on its own line.
wordBreak: "break-word",
}}
>
{words.slice(0, revealed).map((word, index) => (
<motion.span
key={index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={wordTransition}
style={{ display: "inline" }}
>
{word}{" "}
</motion.span>
))}
{/* The caret is a block, not a glyph, so it may scale: folding it
down from the baseline is what reads as "the write head is
gone". Text next to it never moves. */}
<motion.span
aria-hidden
initial={false}
animate={
stopped || finished
? { opacity: 0, scaleY: 0 }
: reduceMotion
? { opacity: 1, scaleY: 1 }
: { opacity: [1, 0.15, 1], scaleY: 1 }
}
transition={
stopped || finished
? { duration: cfg.caretCollapse, ease: "easeIn" }
: reduceMotion
? { duration: 0 }
: { duration: 1.05, repeat: Infinity, ease: "easeInOut" }
}
style={{
display: "inline-block",
width: 2,
height: "0.95em",
marginBottom: "-0.13em",
borderRadius: 1,
background: accent,
transformOrigin: "bottom center",
}}
/>
</p>
{stopped && (
<div style={{ display: "grid", gap: 8 }}>
{/* The rule is drawn, not faded: a line sweeping shut reads as
"this answer was closed here", where a fade reads as chrome. */}
<motion.div
aria-hidden
initial={reduceMotion ? false : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.ruleDuration, ease: [0.32, 0.72, 0, 1] }
}
style={{
height: 1,
background: tone(16),
transformOrigin: "left center",
}}
/>
<motion.div
role="status"
aria-live="polite"
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.noteRiseY }
}
animate={{ opacity: 0.62, y: 0 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: {
delay: cfg.ruleDuration * 0.5,
y: cfg.noteSpring,
opacity: { duration: 0.2, ease: "easeOut" },
}
}
style={{
display: "flex",
alignItems: "center",
gap: 7,
fontSize: 12,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" aria-hidden>
<rect
x="2.6"
y="2.6"
width="6.8"
height="6.8"
rx="1.6"
fill="currentColor"
/>
</svg>
<span>{stoppedNote}</span>
</motion.div>
</div>
)}
<div style={{ display: "flex" }}>
<button
type="button"
onClick={handleStop}
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "7px 12px",
borderRadius: 9,
background: tone(7),
color: "inherit",
border: `1px solid ${tone(14)}`,
font: "inherit",
fontSize: 12.5,
fontWeight: 550,
lineHeight: 1,
cursor: "pointer",
}}
>
{/* Both glyphs share one grid cell so the swap can never nudge
the label, and the label itself only ever cross-fades. */}
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 12,
height: 12,
}}
>
<motion.span
initial={false}
animate={{ opacity: streaming ? 1 : 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ gridArea: "1 / 1", display: "grid" }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<rect
x="2.4"
y="2.4"
width="7.2"
height="7.2"
rx="1.8"
fill="currentColor"
/>
</svg>
</motion.span>
<motion.span
initial={false}
animate={{ opacity: streaming ? 0 : 1 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ gridArea: "1 / 1", display: "grid" }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M10.2 6a4.2 4.2 0 1 1-1.4-3.1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M10.4 1.6v2.6H7.8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</span>
<span
style={{ display: "grid", placeItems: "start", whiteSpace: "nowrap" }}
>
<motion.span
initial={false}
animate={{ opacity: streaming ? 1 : 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ gridArea: "1 / 1" }}
>
{stopLabel}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: streaming ? 0 : 1 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ gridArea: "1 / 1" }}
>
{restartLabel}
</motion.span>
</span>
</button>
</div>
</div>
);
}About this pattern
The other half of a streaming answer: what happens when the reader has seen enough and hits the halt control. Left alone, a cut stream looks like a crash — the words simply quit mid-sentence and the caret keeps blinking over nothing. Here the caret folds down from the baseline, a hairline sweeps shut under the last line, and a small note settles below to say the halt was deliberate. Everything after the click is fast and monotonic, because the reader made a decision and the interface should agree with it rather than mourn it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A halt control replaces send while a reply streams, and the cut reply keeps whatever had arrived.
Related patterns
- Reasoning Steps UnfoldA collapsed trace line opens into numbered reasoning steps, each arriving as the panel grows.
- Streaming Text RevealWords arrive one at a time behind a soft caret — the answer is being written, not loaded.
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.