# AI Streaming Markdown — React AI Component

> Renders a markdown answer as it streams: each word fades up out of a blur, with headings, lists, bold and inline code formatted as they arrive.

- Category: [AI Elements](https://pixelpapa.com/components/ai.md)
- Tags: ai, streaming, markdown, text, fade
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/ai/ai-streaming-markdown

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/ai-streaming-markdown.json"
```

The file is written to `components/pixelpapa/ai-streaming-markdown.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { RefreshCw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type Word = { text: string; bold: boolean; code: boolean; index: number };
type MdBlock = { kind: "h" | "p" | "li"; words: Word[] };

/** A deliberately small markdown subset: `#` headings, `-` lists, `**bold**`, `code`. */
function parse(markdown: string) {
  let index = 0;
  const blocks: MdBlock[] = markdown
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean)
    .map((line) => {
      const kind: MdBlock["kind"] = line.startsWith("#") ? "h" : line.startsWith("- ") ? "li" : "p";
      const body = line.replace(/^(#+|-)\s+/, "");
      const words: Word[] = [];
      // Split on **bold** and `code` spans, then on spaces inside each span.
      body.split(/(\*\*[^*]+\*\*|`[^`]+`)/).forEach((part) => {
        const bold = part.startsWith("**");
        const code = part.startsWith("`");
        const inner = bold ? part.slice(2, -2) : code ? part.slice(1, -1) : part;
        (code ? [inner] : inner.split(/\s+/)).filter(Boolean).forEach((text) => words.push({ text, bold, code, index: index++ }));
      });
      return { kind, words };
    });
  return { blocks, total: index };
}

type StreamingMarkdownProps = {
  markdown: string;
  /** Milliseconds per word. */
  speed?: number;
  /** Change to restart the stream. */
  runKey?: number;
  onDone?: () => void;
  className?: string;
};

export function StreamingMarkdown({ markdown, speed = 38, runKey = 0, onDone, className }: StreamingMarkdownProps) {
  const reduce = useReducedMotion();
  const { blocks, total } = useMemo(() => parse(markdown), [markdown]);
  const [count, setCount] = useState(0);
  const shown = reduce ? total : count;

  useEffect(() => {
    setCount(0);
    const id = setInterval(() => {
      setCount((c) => {
        if (c + 1 >= total) clearInterval(id);
        return Math.min(total, c + 1);
      });
    }, speed);
    return () => clearInterval(id);
  }, [total, speed, runKey]);

  useEffect(() => {
    if (shown >= total) onDone?.();
  }, [shown, total, onDone]);

  const render = (w: Word): ReactNode => (
    <span
      key={w.index}
      className={cn(
        "inline-block",
        w.bold && "font-semibold text-foreground",
        w.code && "rounded-md border border-border bg-muted px-1.5 py-px font-mono text-[0.85em] text-foreground",
      )}
      style={reduce ? undefined : { animation: "pp-ai-streaming-markdown-in 0.5s cubic-bezier(0.22,1,0.36,1) both" }}
    >
      {w.text}
    </span>
  );

  return (
    <div className={cn("space-y-2.5 text-[15px] leading-relaxed text-foreground/85", className)} aria-busy={shown < total}>
      <style>{`@keyframes pp-ai-streaming-markdown-in { from { opacity: 0; transform: translateY(0.35em); filter: blur(6px); } to { opacity: 1; transform: none; filter: blur(0); } }`}</style>
      <p className="sr-only">{markdown.replace(/[#*`]/g, "")}</p>
      {blocks.map((b, bi) => {
        const words = b.words.filter((w) => w.index < shown);
        if (!words.length) return null;
        const last = b.words[b.words.length - 1].index === shown - 1 && shown < total;
        const content = (
          <span aria-hidden className="inline-flex flex-wrap gap-x-[0.28em]">
            {words.map(render)}
            {last && <span className="h-[1.1em] w-[2px] translate-y-[0.2em] animate-pulse rounded-full bg-primary" />}
          </span>
        );
        if (b.kind === "h") return <h3 key={bi} className="pt-1 text-lg font-semibold tracking-tight text-foreground">{content}</h3>;
        if (b.kind === "li")
          return (
            <p key={bi} className="flex gap-2.5 pl-1">
              <span aria-hidden className="mt-[0.62em] size-1.5 shrink-0 rounded-full bg-primary" />
              {content}
            </p>
          );
        return <p key={bi}>{content}</p>;
      })}
    </div>
  );
}

const answer = `# Why the checkout felt slow
The page itself was fine. The delay came from **three blocking calls** that ran one after another before the pay button unlocked.

- Tax lookup waited on the address check, adding about **420 ms**
- The fraud score was requested twice because of a retry in \`useCheckout\`
- Fonts loaded late, so the button shifted after it appeared

Running the first two in parallel and caching the score should bring time to interactive under **one second**.`;

export default function AiStreamingMarkdownDemo() {
  const [run, setRun] = useState(0);
  const [done, setDone] = useState(false);

  return (
    <div className="flex h-[400px] w-full max-w-xl flex-col rounded-3xl border border-border bg-card p-6 shadow-sm">
      <div className="mb-4 flex items-center justify-between">
        <p className="flex items-center gap-2 text-xs text-muted-foreground">
          <span className={cn("size-1.5 rounded-full bg-primary", !done && "animate-pulse motion-reduce:animate-none")} />
          {done ? "Answer complete" : "Streaming answer"}
        </p>
        <button
          type="button"
          onClick={() => (setDone(false), setRun((r) => r + 1))}
          className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          <RefreshCw className="size-3" />
          Replay
        </button>
      </div>
      <StreamingMarkdown markdown={answer} runKey={run} speed={30} onDone={() => setDone(true)} />
    </div>
  );
}
```

## AI prompt

```text
Add the "AI Streaming Markdown" component from PixelPapa UI to this project.

What it is: Renders a markdown answer as it streams: each word fades up out of a blur, with headings, lists, bold and inline code formatted as they arrive.
Category: AI Elements. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/ai-streaming-markdown.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/ai-streaming-markdown.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/ai-streaming-markdown" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More ai elements

- [AI Chat Thread](https://pixelpapa.com/components/ai/ai-chat-thread.md) (Pro): A chat thread where user bubbles spring in from the right and the assistant reply streams in chunks behind a blinking caret, auto-scrolling as it grows.
- [AI Code Block With Copy](https://pixelpapa.com/components/ai/ai-code-block-copy.md) (Pro): A code answer with sliding language tabs, line numbers, highlighted lines that wipe in, and a copy button that morphs into a confirmed tick.
- [AI Model Compare](https://pixelpapa.com/components/ai/ai-model-compare.md): A blind side-by-side: two answers stream at their own speeds with live token stats, then your vote crowns a winner and reveals both model names.
- [AI Prompt Input](https://pixelpapa.com/components/ai/ai-prompt-input.md) (Pro): A chat composer with an auto-growing textarea, removable attachment chips, a model picker popover and a send button that morphs into stop while generating.
- [AI Source Citations](https://pixelpapa.com/components/ai/ai-source-citations.md): An answer with numbered citation markers; hovering or focusing one pops a source card and lights the matching source in the strip below.
- [AI Suggestion Chips](https://pixelpapa.com/components/ai/ai-suggestion-chips.md) (Pro): Prompt suggestions grouped by intent; chips cascade in when you switch group, and picking one types its prompt into the input.
