Skip to content
Scrim UI

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.

messagestreamingassistant

Preview

AssistantGenerating

Presets

Props

40

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-message

Follows 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-message

Component 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
streaming-message.tsx
"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

Related Components