# Markdown

> A React Markdown renderer in the library's look: scrolling tables, highlighted code, task lists and a flicker-free streaming mode for AI.

- Type: React component (Data Display)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add markdown`
- Docs: https://wingo-ui.com/components/markdown

## Interaction

Read it like any page: headings keep their levels, links are always underlined (external ones get a small arrow and open in a new tab), done tasks are struck through, and fenced code gets a highlighted block with a copy button. A table wider than the column scrolls sideways inside its own rounded box with soft edge fades, never the page; once it overflows it is a focusable region, so Tab reaches it and the arrow keys scroll it. Tab also stops on each link (Enter follows it), on every code block's scroller and on its copy button. An image that fails to load shows its alt text instead of a broken icon. With streaming on, text arrives without flicker: half-typed bold, code spans, links and table headers stay hidden until they complete, only the last block re-renders, each new block fades in and a caret pulses at the end of the text. Press Replay as a stream in the playground to replay the answer. On a phone the text stays at 15px, long words and links wrap, and tables and code scroll with a swipe. Reduced motion drops the fade and stills the caret.

## Installation

```bash
npx wingo-ui@latest add markdown
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/markdown` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/markdown.tsx`

npm dependencies: `npm i react-markdown remark-gfm lucide-react`

Wingo UI dependencies (installed automatically):

- [Code Block](https://wingo-ui.com/components/code-block) ([Markdown](https://wingo-ui.com/components/code-block.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useEffect, useRef, useState } from "react"
import { Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Markdown } from "@/components/ui/markdown"
import { mockStream } from "@/lib/ai-chat"

const answer = `I found **3 overdue invoices**, **$22,775.95** in total:

| Client | Invoice | Amount |
|---|---|---:|
| Acme Corp | #2026-0405 | $12,480.00 |
| Greenleaf Pharmacy | #2026-0371 | $7,315.20 |

Shall I draft the emails?`

export function AssistantAnswer() {
  const [text, setText] = useState("")
  const [streaming, setStreaming] = useState(false)
  const stream = useRef<AbortController | null>(null)
  useEffect(() => () => stream.current?.abort(), [])

  async function generate() {
    stream.current?.abort()
    const controller = new AbortController()
    stream.current = controller
    setText("")
    setStreaming(true)
    for await (const delta of mockStream(answer, { signal: controller.signal })) setText((t) => t + delta)
    // a restart aborted this stream: the new one owns the flag
    if (stream.current === controller) setStreaming(false)
  }

  return (
    <div className="flex flex-col gap-3">
      <Button variant="soft" size="sm" leftIcon={<Sparkles />} onClick={generate}>
        Generate
      </Button>
      <Markdown streaming={streaming} variant="compact">
        {text}
      </Markdown>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `string \| number \| null` | `` "I found **3 overdue invoices** in September, **$22,775.95** in total:\n\n\| Client \| Invoice \| Amount \| Late \|\n\|---\|---\|---:\|---:\|\n\| Acme Corp \| #2026-0405 \| $12,480.00 \| 1 day \|\n\| Greenleaf Pharmacy \| #2026-0371 \| $7,315.20 \| 53 days \|\n\| West Side Books \| #2026-0366 \| $2,980.75 \| 57 days \|\n\n**What I suggest:**\n1. A polite reminder to Acme Corp, the due date passed yesterday.\n2. A call to Greenleaf Pharmacy: Daniel Reed, `(503) 555-0118`.\n3. For West Side Books, a formal notice. I can use the **Payment notice** template.\n\nShall I draft the emails?" `` | The markdown source (GitHub flavored: tables, task lists, strikethrough, autolinks, footnotes). null, undefined and an empty string render nothing; other values go through String(). Raw HTML in the source is never rendered. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Text size and line height: sm 14 / 24px (side panels, reasoning), md 15 / 28px (chat, notes, comfortable on a phone), lg 16 / 28px (documents). Headings, code and tables scale with it. |
| `variant?` | `"prose" \| "compact"` | `"prose"` | prose is a document's rhythm: blocks 1em apart and headings up to 1.6em. compact is a chat's rhythm: blocks 0.75em apart and headings never above 1.2em, so an answer never shouts. |
| `streaming?` | `boolean` | `false` | Text is still arriving. Half-typed syntax is repaired (completeMarkdown), only the last top-level block re-parses per chunk, new blocks fade in, a caret shows at the end and the root gets aria-busy. Turn it off when the answer completes: the whole source then renders in one pass. |
| `caret?` | `"dot" \| "bar" \| "block" \| "none"` | `"dot"` | The caret at the end of streaming text: a pulsing 10px dot, a thin text-high bar, a block cursor, or none. It sits inline after the last word, and on a line of its own after a code block. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the links, the checked task boxes and the blockquote rail. neutral keeps links in the text color with a soft underline that darkens on hover. |
| `color?` | `string` | `""` | Any CSS color. Replaces the tone for links, task boxes and the quote rail, mixed for contrast in both themes. |
| `codeAccent?` | `string` | `""` | The accent of fenced code blocks, a hex color that the whole code theme is built from. Empty keeps code-block's own; set it app-wide with UIProvider defaults={{ "code-block": { accent } }}. |
| `codeLineNumbers?` | `boolean` | `false` | A line-number gutter in fenced code blocks. |
| `codeMaxHeight?` | `number` | `480` | The tallest a code block gets, in px; longer code scrolls inside its block. 0 lets it grow with the code. |
| `headingIds?` | `boolean` | `false` | Gives h1 to h4 an id from slugifyHeading (What's new becomes whats-new, repeats get -2, -3), for #anchors and a table of contents. Ids a plugin already set are kept. |
| `linkTarget?` | `"auto" \| "_blank" \| "_self"` | `"auto"` | auto opens absolute http(s) links in a new tab with rel noopener noreferrer nofollow and keeps relative links and #anchors in place; _blank opens every link but #anchors in a new tab; _self never does. Links that open a new tab show a small arrow and say so to screen readers. |
| `allowImages?` | `boolean` | `true` | false renders every image as a link to it with its alt text, so untrusted model output can never load a tracking pixel by itself. |
| `breaks?` | `boolean` | `false` | A single newline becomes a line break, as in chat apps and GitHub comments, so signatures, addresses and verse keep their lines. Off follows CommonMark, where a single newline is a space. Code keeps its newlines either way. |
| `components?` | `Components (react-markdown)` |  | Replace any element, e.g. render crm://client/c01 links as chips. Yours win over the built-in look; each receives the element's props plus the hast node. A code override also receives fenced code (className language-x), as in react-markdown. Keep the object stable (module scope or useMemo), or every block re-renders. |
| `remarkPlugins?` | `PluggableList` |  | Remark plugins, appended after remark-gfm (always on, single tildes stay text). Keep the array stable. |
| `rehypePlugins?` | `PluggableList` |  | Rehype plugins, run before the heading ids and the caret. Keep the array stable. |
| `urlTransform?` | `(url: string, key: string, node: Element) => (string \| null \| undefined)` |  | Runs on every href and src first. The default, react-markdown's defaultUrlTransform, drops javascript: and other unsafe protocols. An empty result renders the link text without a link. Allow your own scheme with url.startsWith("crm://") ? url : defaultUrlTransform(url). |
| `onLinkClick?` | `(href: string, event: MouseEvent<HTMLAnchorElement>) => void` |  | Called on every link click, for in-app navigation; call event.preventDefault() to stop the browser. The newest handler is used, so an inline function is fine. |
| `labels?` | `Partial<MarkdownLabels>` |  | Every string it reads out: streaming "Generating" (sr-only after the caret), image "Image", taskDone "Done", taskOpen "Not done", code (language) => `${language} code`, newTab "(opens in a new tab)", footnotes "Footnotes", footnoteBack (index) => `Back to reference ${index}`. |
| `classNames?` | `Partial<Record<MarkdownSlot, string>>` |  | Extra classes for the parts: heading, paragraph, link, list, listItem, blockquote, code (the block around a CodeBlock), inlineCode, tableWrapper (the bordered box), table, hr, image, caret. |
| `className?` | `string` |  | Extra classes for the root, merged last. The root spreads every other div prop (id, aria-*, data-*). |

## Parts

### MarkdownLink (component)

The built-in link (target, rel, the new-tab arrow and its sr-only text, onLinkClick), exported so a components.a override can hand back the links it does not handle: href?.startsWith("crm://") ? <Chip /> : <MarkdownLink {...props} />. Props: MarkdownLinkProps.

### completeMarkdown (function)

The streaming repair, pure: closes an open code fence, appends closers for unmatched ** __ ~~ ` * _ (snake_case and 2*3 stay as they are), drops a marker with nothing after it, turns a half-typed [text](htt into text, holds a table back until its separator row arrives and drops half-typed list markers, # and >. Never touches code. Signature: (text: string) => string.

### splitMarkdownBlocks (function)

Splits a source into top-level blocks at blank lines, keeping code fences, loose lists and indented continuations whole. The unit the streaming memo works in. Signature: (text: string) => string[].

### markdownToText (function)

Plain text for copying and screen reader announcements: syntax stripped, list items on their own lines (bullets become •), table cells joined with tabs, code kept as written. Signature: (text: string) => string.

### slugifyHeading (function)

The id headingIds uses: lowercase, accents folded, anything but letters and digits turned into dashes (What's new in 4.2 becomes whats-new-in-4-2). Signature: (text: string) => string.

### DEFAULT_MARKDOWN_LABELS (constant)

The English strings, to spread under your own or translate once in UIProvider defaults. Type: MarkdownLabels.

## Examples

- **Streaming**: The email answer streams letter by letter: bold, the quote and the SQL fence never show raw, the caret follows the text, and breaks keeps the signature on two lines.
- **Document**: size lg, prose and headingIds for release notes, with a table of contents fed by slugifyHeading.
- **Code heavy**: Bash, TypeScript and JSON fences with a brand accent and line numbers; long code scrolls in its block.
- **Untrusted output**: allowImages off and linkTarget _blank: the pixel becomes a link, javascript: loses its link, raw HTML renders nothing.
- **Sizes and variants**: sm, md and lg side by side; switch between the compact chat rhythm and the prose document rhythm.
- **Custom components**: components.a turns crm:// links into client chips and hands every other link back to MarkdownLink; urlTransform lets the scheme through.

## See also

- [Code Block](https://wingo-ui.com/components/code-block) ([Markdown](https://wingo-ui.com/components/code-block.md))
- [Table Of Contents](https://wingo-ui.com/components/table-of-contents) ([Markdown](https://wingo-ui.com/components/table-of-contents.md))
- [Collapsible](https://wingo-ui.com/components/collapsible) ([Markdown](https://wingo-ui.com/components/collapsible.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))

---

Canonical: https://wingo-ui.com/components/markdown
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
