AI Patterns

text

Streaming Text

A streamed answer with inline sources, actions, and follow-ups.

"use client";

import * as React from "react";
import { motion } from "motion/react";
import { Check, Copy, Globe, ThumbsDown, ThumbsUp } from "lucide-react";

export type StreamSegment = { type: "text"; content: string } | { type: "source"; label: string };

export interface StreamingTextProps {
  segments: StreamSegment[];
  /** Milliseconds between each revealed character. */
  speed?: number;
  followUps?: string[];
  onComplete?: () => void;
  className?: string;
}

type Token = { type: "char"; value: string } | { type: "source"; value: string };

function tokenize(segments: StreamSegment[]): Token[] {
  const tokens: Token[] = [];
  for (const segment of segments) {
    if (segment.type === "text") {
      for (const char of segment.content) tokens.push({ type: "char", value: char });
    } else {
      tokens.push({ type: "source", value: segment.label });
    }
  }
  return tokens;
}

export function StreamingText({ segments, speed = 18, followUps, onComplete, className }: StreamingTextProps) {
  const tokens = React.useMemo(() => tokenize(segments), [segments]);
  const [count, setCount] = React.useState(0);
  const done = count >= tokens.length;

  React.useEffect(() => {
    if (count >= tokens.length) return;
    const id = window.setTimeout(() => setCount((c) => c + 1), speed);
    return () => window.clearTimeout(id);
  }, [count, tokens.length, speed]);

  React.useEffect(() => {
    if (done) onComplete?.();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [done]);

  const nodes: React.ReactNode[] = [];
  let buffer = "";
  let key = 0;
  for (let i = 0; i < count; i++) {
    const token = tokens[i];
    if (token.type === "char") {
      buffer += token.value;
    } else {
      if (buffer) {
        nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);
        buffer = "";
      }
      nodes.push(<SourceChip key={key++} label={token.value} />);
    }
  }
  if (buffer) nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);

  const plainText = segments.map((s) => (s.type === "text" ? s.content : "")).join("");

  return (
    <div className={className}>
      <p className="text-sm leading-relaxed text-foreground/90">
        {nodes}
        {!done && <BlinkingCursor />}
      </p>
      {done && (
        <motion.div
          initial={{ opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.2 }}
          className="mt-3 flex flex-wrap items-center gap-1.5"
        >
          <ActionButtons text={plainText} />
          {followUps?.length ? (
            <>
              <span className="mx-1 h-4 w-px bg-border" />
              {followUps.map((label) => (
                <button
                  key={label}
                  type="button"
                  className="rounded-full border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
                >
                  {label}
                </button>
              ))}
            </>
          ) : null}
        </motion.div>
      )}
    </div>
  );
}

function BlinkingCursor() {
  return (
    <motion.span
      aria-hidden
      className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] bg-foreground"
      animate={{ opacity: [1, 1, 0, 0] }}
      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: "linear" }}
    />
  );
}

function SourceChip({ label }: { label: string }) {
  return (
    <span className="mx-1 inline-flex -translate-y-px items-center gap-1 rounded-full border bg-muted px-2 py-0.5 align-middle text-xs">
      <span className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
        <Globe className="size-2.5" />
      </span>
      <span className="font-mono text-muted-foreground">{label}</span>
    </span>
  );
}

function ActionButtons({ text }: { text: string }) {
  const [copied, setCopied] = React.useState(false);

  async function handleCopy() {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1500);
  }

  return (
    <div className="flex items-center gap-0.5 text-muted-foreground">
      <button
        type="button"
        onClick={handleCopy}
        aria-label="Copy"
        className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
      >
        {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
      </button>
      <button
        type="button"
        aria-label="Good response"
        className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
      >
        <ThumbsUp className="size-3.5" />
      </button>
      <button
        type="button"
        aria-label="Bad response"
        className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
      >
        <ThumbsDown className="size-3.5" />
      </button>
    </div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Streaming Text

## Summary
An answer that reveals itself progressively, as if it's being generated live, with inline source citations that appear at their exact position in the text — followed by quick actions and follow-up suggestions once the stream finishes.

## When to use
- Displaying an LLM-generated answer as it's actually produced (or a scripted approximation of that for a demo).
- Answers that cite specific sources inline, where the citation should feel attached to the exact claim it supports rather than listed separately at the end.

## When not to use
- Content the user already has in full, such as re-rendering a past message when a conversation reloads — show it fully formed, don't replay the reveal animation.
- Very long documents (multiple paragraphs+), where a full character-by-character reveal just delays reading. Consider revealing by paragraph/chunk instead.
- Attaching follow-up suggestion chips to content that isn't actually actionable — don't add them out of habit.

## Anatomy
- Streamed body text.
- Inline source chip(s): a small pill with an icon and a domain/source name, appearing inline mid-sentence.
- A cursor at the current write position, visible only while streaming.
- Post-stream action row: copy, thumbs up/down.
- Post-stream follow-up suggestion chips.

## Behavior
- Text reveals at a constant, fast pace — slow enough to read as "live", fast enough that it never feels like a gimmick or an artificial delay.
- A source chip appears as a whole unit at its position; it does not itself type in character by character.
- The cursor disappears the instant streaming completes.
- The action row and follow-up chips appear only after the stream finishes, never during — so they don't compete for attention while the user is still reading.
- Follow-up chips are optional next steps the user can click, not required actions.

## Content guidelines
- Citations are short (a domain or short source name), not a full URL.
- Follow-up suggestions are phrased as something the user would say next, in their voice ("Show trend chart"), not the agent's voice ("I can show a trend chart").

## Accessibility
- The streaming region should be an `aria-live="polite"` region; announcing every character is disruptive, so throttle or announce only on completion.
- Respect `prefers-reduced-motion` by rendering the full text immediately instead of animating the character reveal.
- The cursor is decorative — mark it `aria-hidden`.

## Related patterns
- Often follows a Thinking Loader / Expandable Trace pair: loader while working, trace for the audit trail, this component for the actual answer.