Skip to content

A model's reply formatted as it streams, each chunk fading in, never flashing syntax.

01Preview

Checkout broke after yesterday’s deploy. How do I roll back?

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { StreamingText } from "@/components/ui/streaming-text";

<StreamingText
  text={message.content}
  status={isStreaming ? "streaming" : "done"}
/>

// Keep partial output when the user stops or the connection drops.
<StreamingText text={partial} status="stopped" onContinue={resume} />
<StreamingText text={partial} status="error" onRetry={retry} />

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react";
import { CopyButton } from "@/components/ui/copy-button";
import { cn } from "@/lib/cn";
import { Alert, ArrowUpRight, Refresh } from "@/lib/icons";
import { ease } from "@/lib/motion";

export type StreamStatus = "streaming" | "done" | "stopped" | "error";

type Chunk = { start: number; at: number };
type View = { shown: string; chunks: Chunk[] };

/** How long a chunk takes to fade in, and how long it keeps its own span. */
const FADE = 380;
const FRESH = 700;
/** Revealing at most every other frame keeps the number of chunks (and spans) down. */
const MIN_STEP = 32;

// The server's pacing is bursty: forty characters at once, then nothing for 300ms.
// This reveals whatever has arrived over the next few frames, a whole word at a
// time, so the text flows instead of landing in lumps. It never runs behind the
// stream by more than ~100ms, and never slower than it: the whole backlog is
// always out within a handful of frames.
function useRevealedText(text: string, { streaming, smooth }: { streaming: boolean; smooth: boolean }): View {
  const model = useRef<View & { last: number }>({ shown: text, chunks: [], last: 0 });
  const [view, setView] = useState<View>({ shown: text, chunks: [] });

  useEffect(() => {
    const m = model.current;
    let raf = 0;
    let dirty = false;

    // A different text (a regenerated reply, another message) starts over. When
    // nothing is streaming there is nothing to reveal: show it as it is.
    if (!text.startsWith(m.shown)) {
      m.shown = streaming ? "" : text;
      m.chunks = [];
      m.last = 0;
      dirty = true;
    }

    const step = (now: number) => {
      const backlog = text.length - m.shown.length;
      if (backlog > 0 && !(smooth && now - m.last < MIN_STEP)) {
        const dt = m.last ? Math.min(now - m.last, 100) : MIN_STEP;
        // Catch up on about a third of the backlog per step, faster once the stream has ended.
        let end = smooth ? m.shown.length + Math.max(3, Math.ceil(backlog * Math.min(1, dt / (streaming ? 100 : 50)))) : text.length;
        if (end < text.length) {
          // Finish the word in progress so a word never arrives in two halves.
          const next = text.slice(end, end + 18).search(/\s/);
          end = next >= 0 ? end + next : end + 18;
        }
        end = Math.min(end, text.length);
        m.chunks = [...m.chunks.filter((c) => now - c.at < FRESH * 2), { start: m.shown.length, at: now }];
        m.shown = text.slice(0, end);
        m.last = now;
        dirty = true;
      }
      if (dirty) {
        setView({ shown: m.shown, chunks: m.chunks });
        dirty = false;
      }
      if (m.shown.length < text.length) raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [text, streaming, smooth]);

  return view;
}

/**
 * The text a streaming reply should show right now, revealed a word at a time
 * without falling behind the stream. Use it on its own when you render the text
 * yourself.
 */
export function useSmoothText(text: string, streaming = true) {
  return useRevealedText(text, { streaming, smooth: true }).shown;
}

/* -------------------------------------------------------------------------------------------------
 * Blocks: a small, forgiving markdown reader. It knows what an unfinished block
 * looks like, so a reply never flashes raw syntax on its way to being formatted.
 * -----------------------------------------------------------------------------------------------*/

type Range = [number, number];
type Block =
  | { kind: "p"; start: number; lines: Range[] }
  | { kind: "h"; start: number; level: number; lines: Range[] }
  | { kind: "quote"; start: number; lines: Range[] }
  | { kind: "list"; start: number; ordered: boolean; items: { start: number; n: number; depth: number; lines: Range[] }[] }
  | { kind: "code"; start: number; lang: string; body: Range; closed: boolean }
  | { kind: "hr"; start: number };

const fenceRe = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)/;
const headingRe = /^ {0,3}(#{1,6})\s+/;
const bulletRe = /^(\s*)([-*+])\s+/;
const orderedRe = /^(\s*)(\d{1,9})[.)]\s+/;
const quoteRe = /^ {0,3}>\s?/;
const hrRe = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/;
// A last line that could still turn into a heading, list, quote, rule or fence:
// hold it back until the next character decides, instead of flashing "#" or "-".
const pendingRe = /^\s*(#{1,6}|[-*+]|\d{1,9}[.)]?|>|`{1,3}[\w+#.-]*|~{1,3}|-{2,}|\*{2,}|_{2,})$/;

function parse(src: string, streaming: boolean): { blocks: Block[]; end: number } {
  const lines: Range[] = [];
  let at = 0;
  while (at <= src.length) {
    const nl = src.indexOf("\n", at);
    const e = nl < 0 ? src.length : nl;
    lines.push([at, e]);
    if (nl < 0) break;
    at = nl + 1;
  }
  let end = src.length;
  const last = lines[lines.length - 1];
  if (streaming && last && pendingRe.test(src.slice(last[0], last[1]))) {
    end = last[0];
    lines.pop();
  }

  const blocks: Block[] = [];
  const line = (r: Range) => src.slice(r[0], r[1]);
  let i = 0;
  while (i < lines.length) {
    const r = lines[i];
    const text = line(r);
    if (!text.trim()) {
      i++;
      continue;
    }
    const fence = fenceRe.exec(text);
    if (fence) {
      const marker = fence[1];
      const bodyStart = r[1] + 1;
      let j = i + 1;
      while (j < lines.length && !line(lines[j]).trimStart().startsWith(marker)) j++;
      const closed = j < lines.length;
      const bodyEnd = closed ? Math.max(bodyStart, lines[j][0] - 1) : end;
      blocks.push({ kind: "code", start: r[0], lang: fence[2], body: [Math.min(bodyStart, end), Math.max(Math.min(bodyStart, end), bodyEnd)], closed });
      i = closed ? j + 1 : lines.length;
      continue;
    }
    if (hrRe.test(text)) {
      blocks.push({ kind: "hr", start: r[0] });
      i++;
      continue;
    }
    const heading = headingRe.exec(text);
    if (heading) {
      blocks.push({ kind: "h", start: r[0], level: heading[1].length, lines: [[r[0] + heading[0].length, r[1]]] });
      i++;
      continue;
    }
    if (quoteRe.test(text)) {
      const out: Range[] = [];
      while (i < lines.length && quoteRe.test(line(lines[i]))) {
        const m = quoteRe.exec(line(lines[i]))!;
        out.push([lines[i][0] + m[0].length, lines[i][1]]);
        i++;
      }
      blocks.push({ kind: "quote", start: r[0], lines: out });
      continue;
    }
    const first = bulletRe.exec(text) ?? orderedRe.exec(text);
    if (first) {
      const ordered = !bulletRe.test(text);
      const items: { start: number; n: number; depth: number; lines: Range[] }[] = [];
      while (i < lines.length) {
        const t = line(lines[i]);
        const m = ordered ? orderedRe.exec(t) : bulletRe.exec(t);
        if (m) {
          items.push({ start: lines[i][0], n: ordered ? Number(m[2]) : 0, depth: Math.min(3, Math.floor(m[1].replace(/\t/g, "  ").length / 2)), lines: [[lines[i][0] + m[0].length, lines[i][1]]] });
          i++;
        } else if (t.trim() && /^\s{2,}/.test(t) && items.length) {
          // An indented line continues the item above it.
          const lead = t.length - t.trimStart().length;
          items[items.length - 1].lines.push([lines[i][0] + lead, lines[i][1]]);
          i++;
        } else break;
      }
      blocks.push({ kind: "list", start: r[0], ordered, items });
      continue;
    }
    const out: Range[] = [];
    while (i < lines.length) {
      const t = line(lines[i]);
      if (!t.trim() || fenceRe.test(t) || headingRe.test(t) || quoteRe.test(t) || bulletRe.test(t) || orderedRe.test(t) || hrRe.test(t)) break;
      out.push(lines[i]);
      i++;
    }
    blocks.push({ kind: "p", start: r[0], lines: out });
  }
  return { blocks, end };
}

const safeHref = (href: string) => (/^(https?:|mailto:)/i.test(href.trim()) ? href.trim() : undefined);

type Ctx = {
  src: string;
  /** Where the visible text ends. Unclosed marks are only forgiven here, and only while streaming. */
  tail: number;
  streaming: boolean;
  fresh: Chunk[];
};

/** Plain text for [a, b), cut into one span per recent chunk so each can fade on its own. */
function runs(ctx: Ctx, a: number, b: number): React.ReactNode[] {
  if (a >= b) return [];
  const out: React.ReactNode[] = [];
  const cuts = ctx.fresh.filter((c) => c.start < b);
  const firstFresh = cuts.find((c, k) => c.start >= a || (cuts[k + 1]?.start ?? Infinity) > a);
  let p = a;
  if (!firstFresh) return [ctx.src.slice(a, b)];
  if (firstFresh.start > a) {
    out.push(ctx.src.slice(a, firstFresh.start));
    p = firstFresh.start;
  }
  for (let k = cuts.indexOf(firstFresh); k < cuts.length && p < b; k++) {
    const q = Math.min(b, cuts[k + 1]?.start ?? b);
    if (q > p) {
      out.push(
        <span key={p} data-chunk="" data-at={cuts[k].at}>
          {ctx.src.slice(p, q)}
        </span>,
      );
    }
    p = q;
  }
  return out;
}

const closer = (src: string, from: number, to: number, mark: string) => {
  for (let j = src.indexOf(mark, from); j >= 0 && j + mark.length <= to; j = src.indexOf(mark, j + 1)) {
    if (src[j - 1] !== " " && src[j - 1] !== "\\") return j;
  }
  return -1;
};

function inline(ctx: Ctx, a: number, b: number): React.ReactNode[] {
  const { src } = ctx;
  const atTail = ctx.streaming && b >= ctx.tail;
  const out: React.ReactNode[] = [];
  let text = a;
  let i = a;
  const flush = (to: number) => {
    out.push(...runs(ctx, text, to));
  };
  while (i < b) {
    const ch = src[i];
    if (ch === "\\" && i + 1 < b && /[\\`*_[\]()#>~-]/.test(src[i + 1])) {
      flush(i);
      text = i + 1;
      i += 2;
      continue;
    }
    if (ch === "`") {
      const j = src.indexOf("`", i + 1);
      const end = j >= 0 && j < b ? j : atTail ? b : -1;
      if (end > i) {
        flush(i);
        out.push(
          <code key={`c${i}`} className="rounded-[5px] border border-line bg-fg/[0.04] px-[5px] py-px font-mono text-[0.88em] text-fg [overflow-wrap:anywhere]">
            {runs(ctx, i + 1, end)}
          </code>,
        );
        i = text = Math.min(b, end + 1);
        continue;
      }
    }
    const double = (src.startsWith("**", i) || src.startsWith("__", i)) && src[i + 2] && src[i + 2] !== " ";
    if (double) {
      const mark = src.slice(i, i + 2);
      const j = closer(src, i + 2, b, mark);
      const end = j >= 0 ? j : atTail ? b : -1;
      if (end > i) {
        flush(i);
        out.push(
          <strong key={`s${i}`} className="font-medium text-fg">
            {inline(ctx, i + 2, end)}
          </strong>,
        );
        i = text = Math.min(b, end + 2);
        continue;
      }
    }
    const single = (ch === "*" || (ch === "_" && !/\w/.test(src[i - 1] ?? ""))) && src[i + 1] && src[i + 1] !== " " && src[i + 1] !== ch;
    if (single) {
      const j = closer(src, i + 1, b, ch);
      const end = j >= 0 && src[j + 1] !== ch ? j : atTail && j < 0 ? b : -1;
      if (end > i) {
        flush(i);
        out.push(<em key={`e${i}`}>{inline(ctx, i + 1, end)}</em>);
        i = text = Math.min(b, end + 1);
        continue;
      }
    }
    if (ch === "[") {
      const close = src.indexOf("]", i + 1);
      if (close >= 0 && close < b && src[close + 1] === "(") {
        const paren = src.indexOf(")", close + 2);
        if (paren >= 0 && paren < b) {
          flush(i);
          const href = safeHref(src.slice(close + 2, paren));
          const label = inline(ctx, i + 1, close);
          out.push(
            href ? (
              <a
                key={`a${i}`}
                href={href}
                target="_blank"
                rel="noopener noreferrer"
                className="text-fg underline decoration-fg-4 underline-offset-[3px] transition-[text-decoration-color] duration-150 hover:decoration-fg-2"
              >
                {label}
                <ArrowUpRight size={12} className="ml-0.5 inline-block -translate-y-px align-middle text-fg-3" />
              </a>
            ) : (
              <Fragment key={`a${i}`}>{label}</Fragment>
            ),
          );
          i = text = paren + 1;
          continue;
        }
        if (atTail) {
          // The address is still arriving: show the words, hide the half-written URL.
          flush(i);
          out.push(<span key={`a${i}`} className="text-fg underline decoration-fg-4 underline-offset-[3px]">{inline(ctx, i + 1, close)}</span>);
          i = text = b;
          continue;
        }
      } else if (atTail && (close < 0 || close >= b)) {
        flush(i);
        out.push(<Fragment key={`a${i}`}>{inline(ctx, i + 1, b)}</Fragment>);
        i = text = b;
        continue;
      }
    }
    i++;
  }
  flush(b);
  return out;
}

/* -------------------------------------------------------------------------------------------------
 * StreamingText
 * -----------------------------------------------------------------------------------------------*/

export type StreamingTextProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Everything received so far. Append to it as tokens arrive; replace it to start over. */
  text: string;
  /** "streaming" while tokens arrive. "stopped" and "error" keep the partial text and say so beneath it. */
  status?: StreamStatus;
  /** Reveal bursts a word at a time over a few frames instead of in lumps. */
  smooth?: boolean;
  /** "md" for the reply itself; "sm" for secondary streams such as reasoning or a side panel. */
  size?: "sm" | "md";
  /** Shown under a stopped reply. */
  stoppedLabel?: string;
  /** Shown under a reply that failed partway. */
  errorLabel?: string;
  /** Adds a Continue button to a stopped reply. */
  onContinue?: () => void;
  /** Adds a Try again button to a failed reply. */
  onRetry?: () => void;
};

export function StreamingText({
  text,
  status = "done",
  smooth = true,
  size = "md",
  stoppedLabel = "Stopped",
  errorLabel = "The reply didn’t finish",
  onContinue,
  onRetry,
  className,
  ref,
  ...rest
}: StreamingTextProps) {
  const reduce = !!useReducedMotion();
  const streaming = status === "streaming";
  const { shown, chunks } = useRevealedText(text, { streaming, smooth: smooth && !reduce });
  const rootRef = useRef<HTMLDivElement | null>(null);

  const latest = chunks.length ? chunks[chunks.length - 1].at : 0;
  const fresh = chunks.filter((c) => latest - c.at < FRESH);
  const { blocks, end } = parse(shown, streaming);
  const ctx: Ctx = { src: shown, tail: end, streaming, fresh };
  const lastBlock = blocks[blocks.length - 1];

  // Each new chunk fades in once. A span that React re-creates (because the
  // text around it became bold or a list) resumes its fade where it was rather
  // than starting again, so formatting never makes old words flicker.
  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root || reduce) return;
    const now = performance.now();
    root.querySelectorAll<HTMLElement>("[data-chunk]:not([data-faded])").forEach((el) => {
      el.setAttribute("data-faded", "");
      const age = now - Number(el.dataset.at);
      if (age >= FADE) return;
      const anim = el.animate([{ opacity: 0, filter: "blur(2px)" }, { opacity: 1, filter: "blur(0px)" }], {
        duration: FADE,
        easing: `cubic-bezier(${ease.out.join(",")})`,
        fill: "backwards",
      });
      anim.currentTime = Math.max(0, age);
    });
  });

  // The caret is re-keyed by every chunk: it holds steady while words arrive and
  // starts to blink only after the stream has gone quiet for half a second.
  const caret = streaming ? <Caret key={`caret-${latest}`} /> : null;

  return (
    <div
      ref={(node) => {
        rootRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      }}
      data-status={status}
      data-size={size}
      aria-busy={streaming || undefined}
      className={cn(
        "flex min-w-0 flex-col [overflow-wrap:break-word]",
        size === "sm" ? "gap-2 text-[12.5px] leading-5 text-fg-2" : "gap-3 text-[13.5px] leading-[22px] text-fg",
        className,
      )}
      {...rest}
    >
      {blocks.map((block) => (
        <BlockView key={block.start} block={block} ctx={ctx} caret={block === lastBlock ? caret : null} />
      ))}
      {!blocks.length && streaming && <p className="min-h-[1lh]">{caret}</p>}

      <AnimatePresence initial={false}>
        {(status === "stopped" || status === "error") && (
          <motion.div
            key={status}
            role="status"
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, transition: { duration: 0.12 } }}
            transition={{ duration: 0.24, ease: ease.out }}
            className={cn("-mt-0.5 flex min-h-7 flex-wrap items-center gap-x-2 gap-y-1 text-[12px] leading-4", status === "error" ? "text-danger" : "text-fg-3")}
          >
            {status === "error" ? <Alert size={14} className="shrink-0" /> : <span aria-hidden className="size-2 shrink-0 rounded-[2px] bg-current" />}
            <span>{status === "error" ? errorLabel : stoppedLabel}</span>
            {status === "stopped" && onContinue && <FooterButton onClick={onContinue}>Continue</FooterButton>}
            {status === "error" && onRetry && (
              <FooterButton onClick={onRetry} icon>
                Try again
              </FooterButton>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function FooterButton({ children, icon, ...rest }: React.ComponentProps<"button"> & { icon?: boolean }) {
  return (
    <button
      type="button"
      className={cn(
        "relative -my-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none",
        "transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75",
        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
        "before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
      )}
      {...rest}
    >
      {icon && <Refresh size={14} className="text-fg-3" />}
      {children}
    </button>
  );
}

function Caret() {
  return (
    <span
      aria-hidden
      data-caret=""
      className={cn(
        "ml-[2px] inline-block h-[1.1em] w-[0.52em] translate-y-[0.2em] rounded-[2px] bg-current opacity-80",
        "animate-caret [animation-delay:500ms] motion-reduce:animate-none",
      )}
    />
  );
}

const headingClass: Record<number, string> = {
  1: "text-[15px] leading-[22px] font-medium tracking-[-0.015em] text-fg",
  2: "text-[14px] leading-[22px] font-medium tracking-[-0.01em] text-fg",
  3: "text-[13.5px] leading-[22px] font-medium text-fg",
};

function Lines({ ctx, lines, caret }: { ctx: Ctx; lines: Range[]; caret?: React.ReactNode }) {
  return (
    <>
      {lines.map((r, k) => (
        <Fragment key={r[0]}>
          {k > 0 && <br />}
          {inline(ctx, r[0], r[1])}
        </Fragment>
      ))}
      {caret}
    </>
  );
}

function BlockView({ block, ctx, caret }: { block: Block; ctx: Ctx; caret: React.ReactNode }) {
  switch (block.kind) {
    case "p":
      return (
        <p>
          <Lines ctx={ctx} lines={block.lines} caret={caret} />
        </p>
      );
    case "h": {
      // A reply is not a page: its headings sit a level or two below the app's.
      const Tag = (["h3", "h4", "h5"] as const)[Math.min(block.level, 3) - 1];
      return (
        <Tag className={cn("-mb-1 pt-1", headingClass[Math.min(block.level, 3)])}>
          <Lines ctx={ctx} lines={block.lines} caret={caret} />
        </Tag>
      );
    }
    case "quote":
      return (
        <blockquote className="border-s-2 border-line-2 ps-3 text-fg-2">
          <Lines ctx={ctx} lines={block.lines} caret={caret} />
        </blockquote>
      );
    case "hr":
      return <hr className="my-1 border-0 border-t border-line" />;
    case "list": {
      const List = block.ordered ? "ol" : "ul";
      const last = block.items[block.items.length - 1];
      return (
        <List role="list" className="flex flex-col gap-1.5">
          {block.items.map((item) => (
            <li key={item.start} value={block.ordered ? item.n : undefined} style={{ marginInlineStart: item.depth * 18 }} className="relative ps-5">
              {block.ordered ? (
                <span aria-hidden className="absolute start-0 top-0 font-mono text-[12px] text-fg-3 tabular">
                  {item.n}.
                </span>
              ) : (
                <span aria-hidden className="absolute start-1.5 top-[calc(0.5lh-2.5px)] size-[5px] rounded-full bg-fg-4" />
              )}
              <Lines ctx={ctx} lines={item.lines} caret={item === last ? caret : null} />
            </li>
          ))}
        </List>
      );
    }
    case "code": {
      const code = ctx.src.slice(block.body[0], block.body[1]);
      const done = block.closed || !ctx.streaming;
      return (
        <div className="overflow-hidden rounded-lg border border-line bg-raised">
          <div className="flex h-8 items-center justify-between gap-2 border-b border-line pl-3 pr-1">
            <span className="truncate font-mono text-2xs uppercase tracking-[0.08em] text-fg-3">{block.lang || "code"}</span>
            <motion.span initial={false} animate={{ opacity: done ? 1 : 0 }} transition={{ duration: 0.16 }} inert={!done}>
              <CopyButton value={code} iconOnly variant="ghost" size="sm" label="Copy code" />
            </motion.span>
          </div>
          <pre className="overflow-x-auto px-3 py-2.5 font-mono text-[12.5px] leading-5 text-fg [scrollbar-width:thin]">
            <code>
              {runs(ctx, block.body[0], block.body[1])}
              {caret}
            </code>
          </pre>
        </div>
      );
    }
  }
}

05Props

StreamingText

PropTypeDefaultDescription
text*stringEverything received so far. Append as tokens arrive; replacing it with a different string starts over.
status"streaming" | "done" | "stopped" | "error""done"Streaming shows the caret and forgives unfinished markdown at the tail. Stopped and error keep the text and say so beneath it.
smoothbooleantrueReveal bursts a word at a time over a few frames. Never slower than the stream; off under reduced motion.
stoppedLabelstring"Stopped"Shown under a stopped reply.
errorLabelstring"The reply didn’t finish"Shown under a reply that failed partway. Say what happened.
onContinue() => voidAdds a Continue button to a stopped reply.
onRetry() => voidAdds a Try again button to a failed reply.

useSmoothText

PropTypeDefaultDescription
text*stringThe received text. Returns the text to show this frame, for rendering it yourself.
streamingbooleantrueWhen false, whatever is left drains twice as fast.

06Notes

Behavior

  • Renders paragraphs, headings (a level below the app's), lists, quotes, rules, fenced code, inline code, bold, italic and links. Unsafe link schemes render as plain text.
  • Nothing jumps as structure appears: a last line that could still become a heading, list, quote or fence ("#", "-", "1.", "``") is held back until the next character decides, an unclosed code fence is already a code block, and unclosed bold, code or a half-written link render as what they will become.
  • Bursty network chunks are spread over the next few frames and snapped to word ends, catching up within about 100ms; the whole backlog is out within a handful of frames.
  • Blocks are keyed by where they start in the text, so earlier paragraphs never re-render into new nodes. Code blocks show Copy only once their fence closes.
  • Stopped and failed replies keep every received word and say so beneath, with Continue or Try again when you pass the handler.

Motion

  • Each arriving chunk fades from 0 and a 2px blur over 380ms on the expo ease-out. Words that React rebuilds (text turning bold mid-fade) resume their fade instead of flashing.
  • The caret holds steady while words arrive and starts a 1.06s blink after 500ms of quiet, so a stall is visible without a spinner.
  • The stopped or error line rises 4px into place in 240ms. Reduced motion turns off the fade, the smoothing and the blink; text appears as it arrives.

Accessibility

  • The reply is aria-busy while streaming, so screen readers don't read half-sentences; the finished text is ordinary, navigable markup.
  • Stopped and error lines are a polite status, and their Continue and Try again are real buttons with 44px touch targets.
  • The caret is aria-hidden. Lists are real lists with their numbers, and links open in a new tab with a visible marker.