Browse all components
Streaming Message UI
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.
Preview
Presets
Props
Also takes onStop, onRegenerate, onComplete.
Agent promptClaude Code · Cursor · any agent
Add the Streaming Message UI component from the Scrim UI registry to this project and use it in the configuration described below.
Streaming Message UI — Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.
## 1. Install
```bash
npx shadcn@latest add @scrimui/streaming-message
```
This writes a single file to `components/ui/streaming-message.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/streaming-message to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { StreamingMessage } from "@/components/ui/streaming-message";
<StreamingMessage
text="Streaming delivers tokens incrementally, so the reader sees partial output while the model is still writing. That changes two things: perceived latency drops sharply, and the interface has to hold a half-finished state that is still legible."
isStreaming
speed={40}
showActions
onStop={() => {}}
onRegenerate={() => {}}
onComplete={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Streaming" state — Token-by-token reveal with a blinking caret and a Stop control.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
## 4. Wire the handlers
`onStop`, `onRegenerate`, `onComplete` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/streaming-messageFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/streaming-messageComponent source
React + Tailwind component using shadcn/ui (button). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button"use client";
import { Button } from "@/components/ui/button";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type StreamingMessageProps = {
text: string;
isStreaming?: boolean;
stopped?: boolean;
speed?: number;
onStop?: () => void;
onRegenerate?: () => void;
onComplete?: () => void;
showActions?: boolean;
avatar?: React.ReactNode;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Caret */
/* ------------------------------------------------------------------ */
function Caret() {
return (
<>
<style>{`@keyframes aiui-caret{50%{opacity:0}} @media(prefers-reduced-motion:reduce){.scrim-stream-caret{animation:none!important}}`}</style>
<span
aria-hidden
className="scrim-stream-caret ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] rounded-full bg-current"
style={{ animation: "aiui-caret 1s steps(1) infinite" }}
/>
</>
);
}
/* ------------------------------------------------------------------ */
/* StreamingMessage */
/* ------------------------------------------------------------------ */
export function StreamingMessage({
text,
isStreaming = false,
stopped = false,
speed = 1,
onStop,
onRegenerate,
onComplete,
showActions = true,
avatar,
className = "",
}: StreamingMessageProps) {
const [count, setCount] = React.useState(isStreaming ? 0 : text.length);
const doneRef = React.useRef(false);
/* Adjust state during render whenever the target text or flag changes,
so the reveal resets without a setState-in-effect */
const [prev, setPrev] = React.useState<{ text: string; isStreaming: boolean }>({
text,
isStreaming,
});
if (prev.text !== text || prev.isStreaming !== isStreaming) {
setPrev({ text, isStreaming });
setCount(isStreaming ? 0 : text.length);
}
/* Reveal loop */
React.useEffect(() => {
if (!isStreaming) return;
doneRef.current = false;
const tick = window.setInterval(() => {
setCount((c) => Math.min(c + speed, text.length));
}, 16);
return () => window.clearInterval(tick);
}, [isStreaming, text, speed]);
/* Fire onComplete once when the reveal finishes */
React.useEffect(() => {
if (isStreaming && count >= text.length && !doneRef.current) {
doneRef.current = true;
onComplete?.();
}
}, [isStreaming, count, text, onComplete]);
const displayed = text.slice(0, count);
return (
<div className={`flex items-start gap-3 ${className}`}>
{avatar && <div className="shrink-0">{avatar}</div>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">Assistant</span>
{isStreaming && (
<span className="inline-flex items-center gap-1 px-0 py-0.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Generating
</span>
)}
{stopped && (
<span className="px-0 py-0.5 text-xs text-muted-foreground">
Stopped generating
</span>
)}
</div>
<div className="mt-3 whitespace-pre-wrap break-words text-sm leading-7 text-foreground">
{displayed}
{isStreaming && <Caret />}
</div>
{/* Actions row */}
{!isStreaming && showActions && onRegenerate && (
<div className="mt-2 flex items-center gap-1">
<Button variant="ghost" size="sm"
type="button"
onClick={onRegenerate}
className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring hover:bg-muted hover:text-foreground"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="15" height="15">
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<path d="M21 3v5h-5" />
</svg>
Regenerate
</Button>
</div>
)}
{/* Stop pill */}
{isStreaming && onStop && (
<Button variant="outline" size="sm"
type="button"
onClick={onStop}
className="mt-2 inline-flex min-h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs text-muted-foreground transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring hover:bg-muted"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="10" height="10">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
Stop generating
</Button>
)}
</div>
</div>
);
}
When to use it
- Feed real tokens from your streaming API — this component just reveals whatever `text` it receives; the source of the text doesn't matter.
- Show a stop control the moment streaming starts. A generation the user can't cancel reads as broken.
- When stopped, freeze the partial text rather than clearing it — the user may want to save or copy it.
- Keep the 'Generating' badge subtle; the blinking caret already signals motion.
- Use white-space handling carefully — long unbroken tokens need wrapping to avoid horizontal overflow.
What breaks in production
- Rendering markdown incrementally — syntax highlighting breaks mid-token. Render plain text while streaming, then upgrade when done.
- Putting the stop button far from the message — it belongs next to the streaming output.
- Resetting scroll position every token. Anchor to the bottom only when the user is already at the bottom.
- Animating with setState per keystroke on long messages — batch into 16ms ticks like a frame loop.
Guides
Most AI products stream by default — but streaming is a UX decision, not a protocol one. Here is when a streaming reveal earns its place, when a single rendered answer is better, and how to tell the difference.
A task that runs for minutes needs more than a spinner: live progress, visible steps, and an escape hatch. Here is how the products that ship long-running agents keep users oriented — and in control.
Every long-running agent needs a way out — but a stop button that discards work is as bad as none. Here is how real products handle interruption: non-destructively, cleanly, and with the partial work kept.
Related Components
The user's chat bubble — right-aligned, with hover actions to copy, edit the prompt, or regenerate the answer.
The action row under an AI reply — copy, regenerate, share and thumbs up/down feedback, revealed on hover.
What to show when a generation fails — a plain-English reason, a retry button, and a countdown for rate limits.
A rendered markdown reply — syntax-highlighted code blocks with copy buttons, plus tables, lists and safe links.