Pro

Markdown

Renders a model's markdown reply while it streams: no flash of raw asterisks, backticks or half-typed tables, code fences open as live code blocks, and HTML in the reply shows as text.

Streaming

Our checkout page gets slow under load. Should we cache the product API?

Everything it renders

Heading one

Heading two

Heading three
Heading four

Plain text with bold, italic, both, struck and inline code. A relative link, an external one and a bare URL: https://uipolish.com/docs.
A single line break stays a line break.

HTML shows as written: <script>alert("hi")</script> <b>not bold</b>. Escaped *stars* stay stars, and snake_case_names stay whole. A javascript: link keeps only its words.

  • A bullet
  • Another, with a nested list
    • Nested once
      • Nested twice
  • Back at the top
  1. Numbered from three
  2. And four
  • Write the cache wrapper
  • Add purge on price edit
  • Load test checkout

A quote, with emphasis inside.

And a second paragraph.


ColumnAligned leftCenteredRight
A table that's wider than the bubblescrolls sideways inside its own boxinstead of1,024
code in a cella linkbold12
Shell
npx shadcn@latest add https://uipolish.com/r/markdown.json

Installation

With UI Polish connected, ask your agent:

Add the UI Polish markdown to this project.

Or with the shadcn CLI:

npx shadcn@latest add https://uipolish.com/r/markdown.json
Package@base-ui/react
Brings inCheckbox, Code block

Renders a model's reply. Pass the markdown so far on every chunk and it reads as a finished answer the whole way through: an unclosed ** is already bold, an unclosed backtick is already code, a code fence opens as a live code block the moment its first line ends, and a table appears once its divider row arrives, adding a row each time one is complete. A quiet caret follows the last word until the reply ends. The parser is small and built in; there's no markdown package to install.

TSX
<Markdown source={reply} streaming={!done} />

It renders paragraphs, # to #### headings, bold, italic, strikethrough, inline code, links and bare URLs, bulleted, numbered and nested lists, task lists, quotes, tables and horizontal rules. Fenced code is the kit's code block, with its language label, copy and wrap.

In a message thread, put it in an assistant message; the message keeps its row of actions and its status:

TSX
<Message from="assistant" status={status} copyText={reply}>  <Markdown source={reply} streaming={status === "streaming"} /></Message>

When to use it

  • The answer of a chat or agent: anything a model writes for a person to read, streamed or loaded from history.
  • Text from a model anywhere else in the product (a summary on a card, an explanation in a panel), so it reads the same as in the chat.

When not to

  • Your own docs or marketing pages: they want a site's typography, anchors and a full CommonMark parser, not chat scale.
  • The model's reasoning: use reasoning, which is secondary text in a window that folds away.
  • A reply that is only code: use the code block on its own.
  • Rich text people edit. This renders; it doesn't edit.

States

StateWhat you seeHow
Waiting for the first wordA caret on its own linestreaming, empty source
StreamingText as it arrives, a caret after the last wordstreaming
Half-typed marker at the endNothing: a lone #, -, 1., >, - [, --- or the start of a fence waits for the rest of its linestreaming
Unclosed bold, italic, strike or codeAlready styled, to the end of the text so farstreaming
Link still being typedIts words, underlined, not yet clickablestreaming
Code fence still openA code block filling in whole words, aria-busy, no caretstreaming
Table header without its dividerNothing yet; the table appears with the dividerstreaming
Table row still being typedThe rows so far; the new one appears wholestreaming
DoneThe reply as written; an unclosed marker now shows as typedstreaming={false}
Table wider than the replyScrolls sideways inside its box; the edge with more fadescontent
Link hoverUnderline goes from the subtle gray to the text color@media (hover: hover)

A block that's finished doesn't render again while text arrives below it, and nothing above the last block moves: a held-back marker is a line that hasn't appeared yet, not one that appears and changes.

Typography. Chat scale: 15px text on a 1.6 line, blocks 12px apart. # is the 22px heading step, ## and ### the text size at semibold with more room above, #### a muted label. The first and last blocks have no outer margin, so it sits flush in a bubble or card.

Heading tags. A reply sits under the page's own headings, so # renders as an h3 by default and the rest follow down to h6. Set headingLevel where the reply's place in the outline is different.

Props

PropTypeDefaultWhat it does
sourcestringThe markdown so far. Pass the whole text each time, not the new chunk.
streamingbooleanfalseText is still arriving: holds back half-typed markers, closes open spans early and shows the caret.
headingLevel1–63The tag a # heading renders as.
classNamestringOn the root <div>. Other <div> props pass through.

parse(source, streaming) and inline(text, open) are exported too, for rendering the same markdown somewhere else.

Safety

  • HTML in the source shows as text. Nothing is set with dangerouslySetInnerHTML.
  • Links follow only http(s), mailto, tel and relative URLs; a javascript: or data: link keeps its words and drops the link. External links open in a new tab with rel="noopener noreferrer", and show their host on hover.
  • An image (![alt](url)) renders as a link to it, never an <img>: a reply can't make the page load a URL, which is how a prompt injection would send data out.

Motion

WhatDurationEasing
Caret breathes, full to a quarter opacity and back--ui-dur-pulse (700ms) each way, repeating--ui-ease-pulse
Link underline darkens on hover--ui-dur-instant (120ms)--ui-ease-color

Text appears as it arrives, with no fade per word: a long answer that animates every token reads as busy. Under prefers-reduced-motion: reduce the caret holds still.

Accessibility

  • The root has aria-busy while streaming; the caret is aria-hidden.
  • Task items are read-only checkboxes ("Done", "Not done"), out of the Tab order: a reply's checklist isn't a control.
  • A table that scrolls is a focusable region, so the keyboard can scroll it; one that fits isn't a tab stop.
  • Headings are real heading elements, so screen reader users can jump through a long answer.
  • Quotes and struck text use --ui-muted, which holds 4.5:1 in both themes.

Before / after

Before: the raw reply, re-rendered by a full markdown library on every token, flashing ** and backticks until each closes and a table as rows of pipes until its divider arrives.

TSX
<ReactMarkdown>{reply}</ReactMarkdown>

After: the same text, styled from the first character.

TSX
<Markdown source={reply} streaming={!done} />