v1.5

Markdown

Renders AI responses written in markdown with Create UI typography, including while the text is still streaming in.

Pro

Why the build fails on CI

Your local cache hides it. On CI the postinstall script runs before the workspace packages are built, so @acme/ui resolves to an empty folder.

  1. Move the build step into prepare.
  2. Add @acme/ui to dependsOn in turbo.json.
  3. Clear the remote cache once with turbo run build --force.
json
{
  "tasks": {
    "build": { "dependsOn": ["^build"] }
  }
}

After that, every pipeline run builds the packages in order.

Description

Markdown takes a markdown string and renders it as Create UI prose: headings, paragraphs, lists, tables, quotes, links, images and fenced code blocks with a copy button. It is a single component built on Streamdown, which parses the source into blocks and memoizes each one, so a response that grows token by token only re-renders the block that changed.

Use it for assistant messages in a chat, streamed summaries, release notes pulled from an API, or any content where the source is markdown and you don't control it. Set isStreaming while the response is still arriving: new words fade in, a caret follows the last word, and unfinished syntax such as an unclosed ** or a half-written code fence renders cleanly instead of flashing raw characters.

Don't use it for text you write by hand in JSX. Compose Text Link, Separator and plain elements directly, which skips the parser. For MDX pages with embedded components, use your MDX pipeline. Body text inherits its size and color from the container, so put Markdown inside something that sets them, such as a chat message bubble.

Markdown is a Create UI Pro component. Install it with a Pro seat.

Installation

pnpm dlx @create-ui/cli add markdown

Usage

import { Markdown } from "@/components/ui/markdown"
<Markdown isStreaming={status === "streaming"}>{text}</Markdown>

Examples

Every example is a Pro preview (marked with a Pro badge).

Typography

Six heading levels, emphasis, strikethrough, inline code, block quotes and horizontal rules. Headings use fixed type tokens (text-heading-h5 down to text-body-xs), while paragraphs follow the size set on the container.

Pro

Release notes

Version 2.4

Highlights

The editor now saves every 10 seconds instead of on blur, so a closed tab no longer loses a draft. Undo history survives the reload, and manual save is gone from the toolbar.

Inline code such as useAutosave() scales with the paragraph around it.

Drafts older than 30 days are archived, not deleted.

Known issues

Safari 16
Workaround pending

Lists

Ordered, unordered, nested and GFM task lists. Markers sit outside the text, so an item that wraps lines up under its first word. Task lists drop the marker and show a disabled checkbox.

Pro

Before you ship the migration:

  1. Freeze writes on the orders table
    • Pause the nightly import job
    • Drain the queue, which usually takes under five minutes on a weekday
  2. Run the migration
  3. Re-enable writes

Checklist for the on-call engineer:

  • Snapshot taken
  • Rollback script reviewed
  • Status page updated

Code blocks

A fenced block renders with a header showing the language (or text when the fence has none), a copy button and a scrollable body capped at 384px. The body is always left to right, even in an RTL layout.

Pro

Add the route handler:

ts
export async function POST(req: Request) {
  const { messages } = await req.json()
  const result = streamText({ model, messages })

  return result.toDataStreamResponse()
}

Then install the SDK:

bash
pnpm add ai @ai-sdk/openai

Tables

GFM tables get a tinted header row and 1px cell borders. The table sits inside a wrapper that scrolls sideways, so wide tables never push the chat layout.

Pro

Here is how the three plans compare for your team of 12:

PlanSeatsMonthly costSSO
StarterUp to 5$49No
TeamUp to 25$199Yes
EnterpriseUnlimitedCustomYes

Team covers everyone and adds SSO, which your security review asked for.

http and https links open in a new tab with rel="noopener noreferrer". Relative paths, #anchors, mailto: and tel: stay in the current tab. Anything else, such as javascript: or data:, renders as plain text.

Pro

The Next.js caching guide covers this in depth. Our own notes live under deployment, and the flags section below lists every option.

Questions go to [email protected].

A link with a script URL renders as plain text: open settings.

Text size

Markdown sets no font size or text color on body text. Put text-paragraph-xs or text-paragraph-sm on the container (or on Markdown through className) and paragraphs, list items, table cells, links and inline code follow it.

Pro

Your trial ends in 3 days. Pick a plan from Settings and your projects stay exactly as they are.

  • Billing starts on the day you upgrade
  • Cancel any time from the same page

Your trial ends in 3 days. Pick a plan from Settings and your projects stay exactly as they are.

  • Billing starts on the day you upgrade
  • Cancel any time from the same page

Custom components

components replaces the renderer for any element. Your entries are merged over the defaults, so you only pass what changes. Define the object outside the component: a new object on every render makes every block render again.

Pro

Rotate the API key before Friday.

The old key keeps working for 24 hours after rotation, so running jobs finish normally.

Update ACME_API_KEY in every environment once the new key is issued.

Streaming

Pass the accumulated text on every update and keep isStreaming on until the stream closes. Words fade in, the caret sits inline after the last word (never after a code block or table), and the copy button stays disabled until the fence closes. Switching isStreaming off keeps the same tree mounted.

Pro

Streaming

Accessibility

Markdown renders plain semantic HTML, so the reading order and element roles come straight from the source. The only focusable parts are links, copy buttons and code block bodies.

KeyDescription
TabMoves focus through links, code block bodies and copy buttons.
EnterFollows the focused link, or copies when a copy button has focus.
SpaceCopies the code when a copy button has focus.
ArrowLeft/RightScrolls a focused code block body sideways when a line overflows.

ARIA notes:

  • The root sets no role. Headings, lists, tables, blockquote, hr (as role="separator") and img keep their native semantics.
  • The copy button is labelled "Copy code" and switches to "Copied" after a successful copy. A visually hidden role="status" region announces the change. Both strings can be changed with copyLabel and copiedLabel on MarkdownCodeBlock.
  • The caret is CSS generated content with empty alternative text, so screen readers that support it skip the glyph.
  • Images keep the alt text from the markdown. An image with no alt text gets alt="" and is treated as decorative.
  • Don't put aria-live on Markdown itself: announcing every token is noise. Mark the message list as a log and announce once per finished response.
  • The word fade is turned off under prefers-reduced-motion: reduce.

Styling

Tailwind override: className lands on the root. Set the prose size and color there, or restyle parts through descendant selectors.

<Markdown className="text-paragraph-sm text-strongest [&_[data-slot=markdown-h2]]:mt-6">
  {text}
</Markdown>

Data slots and attributes: the component sets these for CSS targeting:

  • data-slot="markdown" on the root, with data-streaming while isStreaming is on.
  • data-slot="markdown-block" on the display: contents wrapper around each parsed block.
  • data-slot="markdown-h1" to data-slot="markdown-h6", markdown-paragraph, markdown-strong, markdown-blockquote, markdown-ul, markdown-ol, markdown-li, markdown-sup and markdown-sub on the matching elements.
  • data-slot="markdown-inline-code" on inline code spans.
  • data-slot="markdown-link" on links, with data-external for links that open in a new tab and data-incomplete while a link is still streaming. Script and data URLs are reduced to plain text before rendering; any other protocol outside the allowlist (for example irc:) renders as a <span> with data-blocked.
  • data-slot="markdown-hr" on the horizontal rule (a Separator).
  • data-slot="markdown-image" on images.
  • data-slot="markdown-table-wrapper", markdown-table, markdown-thead, markdown-tbody, markdown-tr, markdown-th and markdown-td on table parts.
  • data-slot="markdown-code-block" on the code block frame, with data-language and data-incomplete while the fence is still open.
  • data-slot="markdown-code-block-header", markdown-code-block-language, markdown-code-block-copy (with data-copied) and markdown-code-block-body on its parts.
  • data-sd-animate on each word span that fades in while streaming.
[data-slot="markdown"][data-streaming]
  [data-slot="markdown-code-block"][data-incomplete] {
  /* ... */
}

Self-contained CSS: the word fade keyframes, the caret and the reduced-motion guard ship inside the component as a single hoisted <style> tag (React dedupes it across messages). There is no stylesheet to import and no Tailwind @source entry to add for Streamdown.

  • Chat Message: the bubble and layout around an assistant turn. Put Markdown inside its content slot.
  • Text Link: use it directly for links you write in JSX. Markdown uses it for every link in the source.
  • Chat Tool: use it for tool call input and output instead of formatting JSON as a markdown code block.

API Reference

Markdown

Parses a markdown string and renders it with Create UI components. Extends React.ComponentProps<"div"> (except children), so any standard div attribute (id, dir, aria-*, etc.) is passed to the root.

Props

PropTypeDefaultDescription
isStreamingbooleanfalseFades new words in, shows the caret, repairs unfinished syntax and disables copy mid-fence.
componentsComponents-Element renderers merged over markdownComponents. Keep the object referentially stable.
classNamestring-Tailwind classes merged via cn(). Set the prose size and color here.
childrenstring-Required. The full markdown source received so far, not the latest chunk.

MarkdownCodeBlock

The fenced code block frame: language label, copy button and scrollable <pre>. Markdown renders it for every fence. Use it directly when you override code in components and want to keep the frame. Extends React.ComponentProps<"div"> (except children).

Props

PropTypeDefaultDescription
codestring-Required. The code to show and copy.
languagestring-Language from the fence info string. Shows text when unset.
copyLabelstring"Copy code"Accessible name of the copy button.
copiedLabelstring"Copied"Accessible name and announcement after a successful copy.
classNamestring-Tailwind classes merged onto the frame via cn().

markdownComponents

The default renderer map Markdown passes to Streamdown, typed as Components. Spread it when you build your own renderer on top of Streamdown, or read an entry to wrap a default.

const components: Components = {
  ...markdownComponents,
  h2: ({ node, ...props }) => (
    <h2 {...props} className="text-heading-h6 mt-8 mb-2" />
  ),
}

Keys: a, blockquote, code, h1 to h6, hr, img, inlineCode, li, ol, p, strong, sub, sup, table, tbody, td, th, thead, tr, ul.

Types

import type { Components } from "@/components/ui/markdown"

Components is re-exported from Streamdown: a map from an HTML tag name (plus inlineCode) to a React component that receives that element's props and a node prop with the parsed hast element. Drop node before spreading props onto the DOM.