Markdown Renderer
Streamdown markdown with shadcn Code Block fences, Mermaid, math, CJK, link safety, and deny-by-default images.
Plus Registry
This component ships from registry.plus.json. Set up @f-ui-plus and a token on the Installation page, then install @f-ui-plus/markdown-renderer below.
Markdown Renderer renders model or document markdown with Streamdown. Fenced code renders through shadcn Code Block, and Mermaid fences render through @streamdown/mermaid. When shadcn or Streamdown ship a fix, re-adding their items picks it up; f-ui only adds policy (images, links), i18n, and the mermaid hand-back.
The sample below is untrusted markdown. Click the external link to see the confirmation. The javascript: link stays plain text. The first image is on the allowlist; the second is withheld.
Release notes
Inline pnpm add stays inline. Fenced code uses shadcn Code Block:
export function sum(a: number, b: number) { return a + b}A normal link asks before it opens: . A javascript: link stays plain text: not a link.
Image not available
Image not available
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
const SOURCE = `## Release notes
Inline \`pnpm add\` stays inline. Fenced code uses shadcn Code Block:
\`\`\`ts
export function sum(a: number, b: number) {
return a + b
}
\`\`\`
| Area | Status |
| --- | --- |
| Markdown | Streamdown |
| Code | shadcn |
$$
E = mc^2
$$
\`\`\`mermaid
graph LR
Source --> Streamdown --> Screen
\`\`\`
A normal link asks before it opens: [Streamdown docs](https://streamdown.ai/docs). A \`javascript:\` link stays plain text: [not a link](javascript:alert(1)).


`
export function MarkdownRendererDemo() {
return (
<MarkdownRenderer
source={SOURCE}
allowedImagePrefixes={["https://picsum.photos/"]}
/>
)
}When To Use
- Assistant replies and untrusted markdown. Pass
isStreamingwhile tokens arrive. - First-party documents. Pass
trustedLinksto skip the external-link confirmation. - Prefer Markdown Document for long documents that need segment virtualization.
- Use Legacy Markdown Renderer only for apps already on the previous engine.
Installing
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/markdown-rendererFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/markdown-rendererThis also installs shadcn code-block. Then add Streamdown's styles and Tailwind sources to your global CSS (paths relative to that file):
@import "katex/dist/katex.min.css";
@import "streamdown/styles.css";
@source "../node_modules/streamdown/dist/*.js";
@source "../node_modules/@streamdown/mermaid/dist/*.js";
@source "../node_modules/@streamdown/math/dist/*.js";
@source "../node_modules/@streamdown/cjk/dist/*.js";Examples
Live Editor
Type on the left. The preview on the right updates as you edit. https://picsum.photos/ images render; any other image stays withheld. Links still ask before they open.
Markdown source
Rendered
Notes
Edit this source. A normal link asks before it opens: . A javascript: link stays plain text: not a link.
Image not available
Image not available
const total = 128"use client"
import { useState } from "react"
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
import { Textarea } from "@/components/ui/textarea"
const INITIAL = `## Notes
Edit this **source**. A normal link asks before it opens: [Streamdown docs](https://streamdown.ai/docs). A \`javascript:\` link stays plain text: [not a link](javascript:alert(1)).


\`\`\`ts
const total = 128
\`\`\`
`
export function MarkdownRendererEditorDemo() {
const [source, setSource] = useState(INITIAL)
return (
<div className="grid items-stretch gap-4 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Markdown source</p>
<Textarea
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
aria-label="Markdown source"
className="h-96 field-sizing-fixed resize-none font-mono text-sm"
/>
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Rendered</p>
<div className="h-96 overflow-auto rounded-lg border border-input p-4">
<MarkdownRenderer source={source} allowedImagePrefixes={["https://picsum.photos/"]} />
</div>
</div>
</div>
)
}Math
By default KaTeX reads $...$, $$...$$, \(...\), and \[...\]. Pass math={{ singleDollar: false }} when dollar amounts should stay text, math={{ texDelimiters: false }} to keep CommonMark's \( and \[ escapes, or math={false} to turn math off. $$...$$ stays on whenever math is enabled.
Inline dollars: .
Inline TeX: .
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
const SOURCE = `Inline dollars: $E = mc^2$.
Inline TeX: \\(a^2 + b^2 = c^2\\).
$$
\\int_0^1 x \\, dx = \\frac{1}{2}
$$
\\[
\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
\\]
`
export function MarkdownRendererMathDemo() {
return <MarkdownRenderer source={SOURCE} />
}First-Party Links
trustedLinks skips the confirmation. https, mailto, and same-page links are normal anchors and open immediately. A javascript: link is still plain text.
Handbook
Streamdown docs opens in a new tab with no confirmation. So does email the owner, and image sources on this page.
A javascript: link stays plain text even here: not a link.
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
const SOURCE = `## Handbook
[Streamdown docs](https://streamdown.ai/docs) opens in a new tab with no confirmation. So does [email the owner](mailto:docs@example.com), and [image sources](#image-sources) on this page.
A \`javascript:\` link stays plain text even here: [not a link](javascript:alert(1)).
`
export function MarkdownRendererTrustedLinksDemo() {
return <MarkdownRenderer source={SOURCE} trustedLinks />
}Streaming With Links And Images
An assistant reply can carry a link, an allowlisted image, and a withheld image while tokens arrive. The copy button stays disabled until the fence closes. Replay restarts the same reply.
"use client"
import { Button } from "@/components/f-ui/button/button"
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
import { useStreamedSource } from "./use-streamed-source"
const SOURCE = `Pulling the chart for this reply.
Read [the handbook](https://streamdown.ai/docs) before you trust the numbers. [Image sources](#image-sources) on this page still asks first. A \`javascript:\` link stays plain text: [not a link](javascript:alert(1)).

The next image is not on the allowlist, so the browser never requests it.

\`\`\`ts
const total = 128
\`\`\`
Done.`
export function MarkdownRendererStreamingDemo() {
const streamed = useStreamedSource(SOURCE)
return (
<div className="flex flex-col gap-4">
<MarkdownRenderer
source={streamed.source}
isStreaming={streamed.streaming}
allowedImagePrefixes={["https://picsum.photos/"]}
/>
<div>
<Button variant="outline" onClick={streamed.replay} disabled={streamed.streaming}>
Replay
</Button>
</div>
</div>
)
}Streaming First-Party Links
The same kind of reply with trustedLinks. Once a URL is complete it is a normal link, including while later tokens are still arriving.
"use client"
import { Button } from "@/components/f-ui/button/button"
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
import { useStreamedSource } from "./use-streamed-source"
const SOURCE = `This reply is first-party, so links open directly while tokens are still arriving.
Open [Streamdown docs](https://streamdown.ai/docs), [email the owner](mailto:docs@example.com), or [image sources](#image-sources) on this page.

\`\`\`ts
const total = 128
\`\`\`
Done.`
export function MarkdownRendererStreamingTrustedDemo() {
const streamed = useStreamedSource(SOURCE)
return (
<div className="flex flex-col gap-4">
<MarkdownRenderer
source={streamed.source}
isStreaming={streamed.streaming}
trustedLinks
allowedImagePrefixes={["https://picsum.photos/"]}
/>
<div>
<Button variant="outline" onClick={streamed.replay} disabled={streamed.streaming}>
Replay
</Button>
</div>
</div>
)
}Image Sources
Remote images need a prefix. data:image renders only when allowDataImages is set. A relative path renders when the resolved page URL matches a prefix, so an app can allow its own files.
Remote allowlist
An allowlisted photo renders. Anything else stays a placeholder.
Image not available
Image not available
Data image off
Image not available
Data image on
Relative path on this site
Image not available
"use client"
import { useEffect, useState } from "react"
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
const DATA_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAARElEQVR42u3PQREAAAQAMEkEk1grKvi622MBFlk9n4WAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAwNUC7oY4LYyP58EAAAAASUVORK5CYII="
const REMOTE = `An allowlisted photo renders. Anything else stays a placeholder.


`
const DATA = ``
const RELATIVE = ``
export function MarkdownRendererImagesDemo() {
const [origin, setOrigin] = useState("")
useEffect(() => {
setOrigin(window.location.origin)
}, [])
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Remote allowlist</p>
<MarkdownRenderer source={REMOTE} allowedImagePrefixes={["https://picsum.photos/"]} />
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Data image off</p>
<MarkdownRenderer source={DATA} />
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Data image on</p>
<MarkdownRenderer source={DATA} allowDataImages />
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Relative path on this site</p>
<MarkdownRenderer
source={RELATIVE}
allowedImagePrefixes={origin ? [`${origin}/demo-documents/`] : []}
/>
</div>
</div>
)
}Security
- Images render only when their URL starts with an entry in
allowedImagePrefixes(default none). A withheld image shows a muted placeholder reading "Image not available" (localized), and the browser never requests it. This blocks the prompt-injection exfiltration Streamdown's security guide describes. - Links open Streamdown's confirmation dialog unless
trustedLinksis set. Dangerous protocols such asjavascript:render as plain text. - HTML goes through Streamdown's sanitize pass before hardening.
API
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | — | Markdown text |
isStreaming | boolean | false | Tokens are still arriving (Streamdown isAnimating) |
math | boolean | { singleDollar?: boolean; texDelimiters?: boolean } | true | KaTeX. Default reads $...$, $$...$$, \(...\), and \[...\]. false disables math. singleDollar: false keeps $5 as text. texDelimiters: false keeps \( and \[ as CommonMark escapes. $$...$$ stays on whenever math is enabled |
trustedLinks | boolean | false | Skip the external-link confirmation |
allowedImagePrefixes | string[] | [] | Image URL allowlist |
allowDataImages | boolean | false | Allow data:image/* sources |
baseOrigin | string | page origin | Origin relative URLs resolve against |
mermaid | MermaidOptions | — | Streamdown mermaid (config, errorComponent). Theme follows light/dark unless config.theme is set |
className | string | — | Root class |
Strings come from Streamdown's translation keys. Override them through <I18nProvider t> with component "markdown-renderer" and the Streamdown key (for example openExternalLink).