AI & Chat

Streaming Text

A token-streaming renderer where arriving words fade in without layout shift, with a block cursor at the write head and markdown-lite bolding.

Install

npx shadcn@latest add @paragon/streaming-text

streaming-text.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

type Token = { text: string; bold: boolean };

/** Splits text into whitespace-preserving word tokens, parsing `**bold**`. */
function tokenize(text: string, parseBold: boolean): Token[] {
  const segments: Token[] = [];
  if (parseBold) {
    const re = /\*\*([^*]+)\*\*/g;
    let last = 0;
    let match: RegExpExecArray | null;
    while ((match = re.exec(text)) !== null) {
      if (match.index > last) {
        segments.push({ text: text.slice(last, match.index), bold: false });
      }
      segments.push({ text: match[1] ?? "", bold: true });
      last = match.index + match[0].length;
    }
    if (last < text.length) {
      segments.push({ text: text.slice(last), bold: false });
    }
  } else {
    segments.push({ text, bold: false });
  }

  const tokens: Token[] = [];
  for (const segment of segments) {
    for (const piece of segment.text.split(/(\s+)/)) {
      if (piece) tokens.push({ text: piece, bold: segment.bold });
    }
  }
  return tokens;
}

export interface StreamingTextProps extends React.ComponentProps<"div"> {
  /** The accumulated text streamed so far. Append-only for best results. */
  text: string;
  /** Shows the block cursor at the write head. */
  streaming?: boolean;
  /** Parse `**bold**` spans (markdown-lite). */
  parseBold?: boolean;
  /** Per-token fade-in duration in ms. */
  fadeDuration?: number;
  /** Renders tokens without the arrival fade. */
  static?: boolean;
}

/**
 * Token-streaming text renderer. Each word that arrives fades in — opacity
 * only, so nothing shifts layout per token — with a subtle block cursor at
 * the write head that disappears on completion.
 *
 * Technique: text splits into whitespace-preserving word spans keyed by
 * index. As `text` grows, existing spans keep their DOM nodes (their fade
 * never restarts) and only newly mounted spans run the enter keyframe,
 * `backwards`-filled so they never flash at full opacity first.
 */
export function StreamingText({
  text,
  streaming = false,
  parseBold = true,
  fadeDuration = 250,
  static: isStatic = false,
  className,
  ...props
}: StreamingTextProps) {
  const tokens = React.useMemo(
    () => tokenize(text, parseBold),
    [text, parseBold],
  );

  return (
    <div
      data-slot="streaming-text"
      aria-live="polite"
      className={cn(
        "text-sm leading-relaxed whitespace-pre-wrap text-foreground",
        className,
      )}
      {...props}
    >
      {!isStatic && (
        <style href="paragon-streaming-text" precedence="paragon">{`
          @keyframes pg-stream-fade {
            from { opacity: 0; }
            to { opacity: 1; }
          }
          @keyframes pg-stream-cursor {
            0%, 100% { opacity: 0.7; }
            50% { opacity: 0.2; }
          }
          @media (prefers-reduced-motion: reduce) {
            [data-stream] [data-token] { animation: none !important; }
            [data-stream] [data-cursor] { animation: none !important; }
          }
        `}</style>
      )}
      <span data-stream="">
        {tokens.map((token, i) => (
          <span
            key={i}
            data-token=""
            className={token.bold ? "font-semibold" : undefined}
            style={
              isStatic
                ? undefined
                : {
                    animation: `pg-stream-fade ${fadeDuration}ms var(--ease-out) backwards`,
                  }
            }
          >
            {token.text}
          </span>
        ))}
        {streaming && (
          <span
            aria-hidden
            data-cursor=""
            className="ml-px inline-block h-[1em] w-[0.45em] translate-y-[0.15em] rounded-[2px] bg-foreground/70"
            style={
              isStatic
                ? undefined
                : { animation: `pg-stream-cursor 1.1s ease-in-out infinite` }
            }
          />
        )}
      </span>
    </div>
  );
}