f-ui
Components

Markdown Renderer

Render user-generated markdown safely with GFM prose, KaTeX math, sanitized HTML tables, Shiki code blocks, and interactive Mermaid diagrams.

Plus Registry

This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/markdown-renderer (pulls code-block and mermaid-renderer).

Markdown Renderer turns a markdown string into styled article content for comments, knowledge bases, AI chat, and other untrusted user input. It ships secure defaults (sanitized HTML when enabled, restricted links, images off by default), GFM via remark-gfm, KaTeX math, Shiki-highlighted fenced code through Code Block, and preview-first Mermaid diagrams through Mermaid Renderer.

When To Use

  • Display user-authored or AI-generated markdown where XSS and open redirects are a concern.
  • You need GFM (tables, task lists, strikethrough) plus polished code fences without wiring react-markdown yourself.
  • Fenced mermaid blocks should render as interactive diagrams (zoom, export, fullscreen), not plain highlighted text.
  • You want a single component for article prose, or a headless hook to reuse the same plugin and element map in a custom layout.
  • Need phrasing markdown inside a label, chip, table cell, or button — use InlineMarkdown (root <span>, no article/streaming). Use MarkdownRenderer for document prose and streaming replies.
  • Large static or host-segmented preview — use Markdown Document (virtualized segments; inherits this component’s KaTeX / HTML tables / image policy; not for streaming).
  • Knowledge-base / document-library List↔Detail peek of processed markdown — example: /showcases/knowledge-base-documents (Sheet Preview tab).

Features

AreaBehavior
Proseprose / dark:prose-invert on the root <article> (standard Tailwind Typography)
GFMTables (local horizontal scroll when wide), task lists, strikethrough, autolinks (via remark-gfm)
Math$…$ / $$…$$, \(...\), and \[…\] via KaTeX by default; fenced and inline code stay literal. Set math={false} for currency-heavy text
HTML tablesAuthor <table> (incl. rowspan / colspan) renders through table chrome when allowHtml is on (default); $…$ inside cell text also becomes KaTeX
Block rhythmCustom fences / tables / Mermaid use my-6 so gaps match prose; article resets first/last child margins
Code fencesCode Block with chrome and line numbers; mainstream + on-demand Shiki grammars; plain-first while highlighting
Mermaid fencesMermaid Renderer (preview-first; errors switch to code view)
Linkshttp, https, mailto, and #fragment only; blocked schemes render as plain text
External linksOpen in a new tab; rel includes nofollow ugc unless trustedLinks
Images (MarkdownRenderer)Off by default; opt in with allowImages, allowedImageHosts, allowedImagePrefixes, or allowDataImages. Default frame is imageVariant="outline" (flush bitmap). Optional imageZoom, imageObjectFit.
StreamingSet isStreaming while the source is still growing: remend repairs incomplete markdown, finished blocks stay memoized, and an unclosed \[ does not swallow the rest of the message
Raw HTMLParsed when allowHtml (default true), then sanitized; scripts and other dangerous tags never become DOM. Set allowHtml={false} to restore literal tag text
ExtensionAppend remarkPlugins / rehypePlugins; shallow-merge components

Tailwind Typography

MarkdownRenderer applies standard prose / dark:prose-invert. Install @tailwindcss/typography and add @plugin "@tailwindcss/typography"; in your global CSS (Tailwind v4). No other f-ui-specific typography setup is required in a normal app layout.

Fumadocs doc sites only

If demos are wrapped in not-prose (as on this site), nested prose does not apply. This repo registers a second typography namespace — fui-md-prose — in src/styles/app.css. The component includes those classes for you; registry consumers can ignore this unless they use the same Fumadocs + not-prose demo pattern.

Installing

Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/markdown-renderer
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/markdown-renderer
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/markdown-renderer
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/markdown-renderer

With a namespace for the public registry only: pnpm dlx shadcn@latest add @f-ui/date-pickermarkdown-renderer itself is @f-ui-plus/markdown-renderer, not on the public index.

registryDependencies: code-block, mermaid-renderer, document-preview. Runtime: react-markdown, remark-gfm, remark-math-extended, rehype-katex, katex, rehype-raw, rehype-sanitize, hast-util-sanitize, shiki, mermaid, hast-util-to-string, remend, marked.

Usage

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

const source = ["# Hello", "", "```ts", "const x = 1;", "```"].join("\n");

export function Page() {
  return <MarkdownRenderer source={source} className="max-w-3xl" />;
}

Examples

Live Editor

Edit the source on the left; the right pane updates with secure rendering. Try a javascript: link or raw <script> — they are stripped or shown as text, not executed.

Markdown source

Rendered

Markdown renderer

User-generated safe markdown with a normal link and a blocked scheme.

List

  • One
  • Two

TypeScript

ts
const n: number = 42;

Mermaid

Loading MermaidLoading diagram…

Raw angle brackets

"use client";

import { useState } from "react";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

const DEFAULT_MARKDOWN = `# Markdown renderer

User-generated **safe** markdown with [a normal link](https://example.com) and a [blocked scheme](javascript:alert(1)).

## List

- One
- Two

## TypeScript

\`\`\`ts
const n: number = 42;
\`\`\`

## Mermaid

\`\`\`mermaid
flowchart LR
  A[Start] --> B[End]
\`\`\`

## Raw angle brackets

<script>alert(1)</script>
`;

export function MarkdownRendererDemo() {
  const [source, setSource] = useState(DEFAULT_MARKDOWN);

  return (
    <div className="grid gap-4 lg:grid-cols-2">
      <div className="flex min-h-[320px] flex-col gap-2">
        <p className="text-muted-foreground text-xs font-medium">
          Markdown source
        </p>
        <textarea
          value={source}
          onChange={(e) => setSource(e.target.value)}
          className="border-input bg-background text-foreground focus-visible:ring-ring min-h-[280px] flex-1 resize-y rounded-md border p-3 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
          spellCheck={false}
          aria-label="Markdown source"
        />
      </div>
      <div className="flex min-h-[320px] flex-col gap-2">
        <p className="text-muted-foreground text-xs font-medium">Rendered</p>
        <div className="border-input bg-card min-h-[280px] flex-1 overflow-auto rounded-md border p-4">
          <MarkdownRenderer source={source} />
        </div>
      </div>
    </div>
  );
}

Inline Fragment

For bold, links, or inline code inside chrome that cannot host an <article>, use InlineMarkdown. Check the button label, chip, table cells, and the blocked javascript: line — phrasing stays inside the host with no article/prose margins. There is no inline mode prop on MarkdownRenderer.

Priority: P1ship today

Status: Ready — see changelog

FieldValue
OwnerAssigned to Ada
NoteUse InlineMarkdown in cells — not an article

Blocked scheme (link text only): unsafe

"use client";

import { InlineMarkdown } from "@/components/f-ui/markdown-renderer/inline-markdown";
import { Button } from "@/components/ui/button";

/**
 * Host surfaces where an `<article>` / prose shell would break layout:
 * button label, chip, table cell, muted status line, blocked scheme.
 */
export function MarkdownRendererInlineFragmentDemo() {
  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-3">
        <Button type="button" variant="outline">
          <InlineMarkdown source={"Open **docs**"} />
        </Button>
        <span className="border-border bg-muted/40 inline-flex items-center rounded-md border px-2 py-0.5 text-xs">
          <InlineMarkdown source={"Priority: `P1` — **ship today**"} />
        </span>
        <p className="text-muted-foreground text-sm">
          Status:{" "}
          <InlineMarkdown
            source={"Ready — see [changelog](https://example.com)"}
            trustedLinks
            baseOrigin="https://app.local"
          />
        </p>
      </div>

      <div className="border-border overflow-hidden rounded-md border">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-border bg-muted/30 border-b text-left">
              <th className="px-3 py-2 font-medium">Field</th>
              <th className="px-3 py-2 font-medium">Value</th>
            </tr>
          </thead>
          <tbody>
            <tr className="border-border border-b">
              <td className="text-muted-foreground px-3 py-2">Owner</td>
              <td className="px-3 py-2">
                <InlineMarkdown
                  source={"Assigned to [Ada](https://example.com/ada)"}
                  trustedLinks
                  baseOrigin="https://app.local"
                />
              </td>
            </tr>
            <tr>
              <td className="text-muted-foreground px-3 py-2">Note</td>
              <td className="px-3 py-2">
                <InlineMarkdown source={"Use `InlineMarkdown` in cells — not an article"} />
              </td>
            </tr>
          </tbody>
        </table>
      </div>

      <p className="text-muted-foreground text-sm">
        Blocked scheme (link text only):{" "}
        <InlineMarkdown source={"[unsafe](javascript:alert(1))"} />
      </p>
    </div>
  );
}

Wide Tables

Multi-column GFM tables wrap in a local horizontal scroll container (w-max table inside overflow-x-auto). This demo uses a narrow card so the table must scroll instead of stretching the page — the same behavior in a chat measure.

Comparison across regions (scroll horizontally when the pane is narrow):

MetricNorth AmericaEMEAAPACLATAMANZMiddle EastAfricaNordicsBeneluxDACH
Active seats12,4809,21014,9023,4412,1081,7768901,2049802,450
Median latency (ms)4862911107488130415558
Error budget remaining99.2%98.7%97.9%99.0%99.4%98.1%97.2%99.6%99.1%98.8%
Last deploy window2026-07-24T18:00Z2026-07-24T16:30Z2026-07-25T02:15Z2026-07-24T22:00Z2026-07-25T01:00Z2026-07-24T20:45Z2026-07-24T19:10Z2026-07-24T15:00Z2026-07-24T14:20Z2026-07-24T13:55Z
"use client";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

/** Many columns + long cell text so the table exceeds a chat-like measure. */
const WIDE_TABLE = [
  "Comparison across regions (scroll horizontally when the pane is narrow):",
  "",
  "| Metric | North America | EMEA | APAC | LATAM | ANZ | Middle East | Africa | Nordics | Benelux | DACH |",
  "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
  "| Active seats | 12,480 | 9,210 | 14,902 | 3,441 | 2,108 | 1,776 | 890 | 1,204 | 980 | 2,450 |",
  "| Median latency (ms) | 48 | 62 | 91 | 110 | 74 | 88 | 130 | 41 | 55 | 58 |",
  "| Error budget remaining | 99.2% | 98.7% | 97.9% | 99.0% | 99.4% | 98.1% | 97.2% | 99.6% | 99.1% | 98.8% |",
  "| Last deploy window | `2026-07-24T18:00Z` | `2026-07-24T16:30Z` | `2026-07-25T02:15Z` | `2026-07-24T22:00Z` | `2026-07-25T01:00Z` | `2026-07-24T20:45Z` | `2026-07-24T19:10Z` | `2026-07-24T15:00Z` | `2026-07-24T14:20Z` | `2026-07-24T13:55Z` |",
].join("\n");

/**
 * Constrains width like a chat / docs measure so the wide GFM table must
 * scroll locally instead of expanding the page.
 */
export function MarkdownRendererWideTableDemo() {
  return (
    <div className="border-input bg-card max-w-md overflow-hidden rounded-md border p-4">
      <MarkdownRenderer source={WIDE_TABLE} />
    </div>
  );
}

Block Rhythm

Paragraphs, tables, and fenced Code Blocks share one vertical rhythm (my-6 on custom blocks that opt out of Typography). Check the gaps above/below the table and the TypeScript fence — this demo keeps the table within the card; wide-table scroll is under Wide Tables above.

Before the table, a short paragraph should sit with even spacing above the grid.

RegionSeatsLatencyBudgetWindowOwnerNotes
North America12,48048ms99.2%18:00ZplatformPrimary
EMEA9,21062ms98.7%16:30ZplatformFailover
APAC14,90291ms97.9%02:15ZgrowthPeak
LATAM3,441110ms99.0%22:00ZgrowthExpanding

After the table, the following TypeScript sample should keep the same vertical gap:

ts
export function summarize(rows: Row[]) {
  return rows.reduce((n, r) => n + r.seats, 0);
}

Closing sentence under the code block — spacing should match the gap above the fence.

"use client";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

/**
 * Mixed prose + wide table + code fence — verifies vertical rhythm (`my-6` on
 * custom blocks) and local table horizontal scroll in one pass.
 */
const MIXED_SOURCE = [
  "Before the table, a short paragraph should sit with even spacing above the grid.",
  "",
  "| Region | Seats | Latency | Budget | Window | Owner | Notes |",
  "| --- | --- | --- | --- | --- | --- | --- |",
  "| North America | 12,480 | 48ms | 99.2% | `18:00Z` | platform | Primary |",
  "| EMEA | 9,210 | 62ms | 98.7% | `16:30Z` | platform | Failover |",
  "| APAC | 14,902 | 91ms | 97.9% | `02:15Z` | growth | Peak |",
  "| LATAM | 3,441 | 110ms | 99.0% | `22:00Z` | growth | Expanding |",
  "",
  "After the table, the following TypeScript sample should keep the same vertical gap:",
  "",
  "```ts",
  "export function summarize(rows: Row[]) {",
  "  return rows.reduce((n, r) => n + r.seats, 0);",
  "}",
  "```",
  "",
  "Closing sentence under the code block — spacing should match the gap above the fence.",
].join("\n");

export function MarkdownRendererBlockRhythmDemo() {
  return (
    <div className="border-input bg-card max-w-lg overflow-hidden rounded-md border p-4">
      <MarkdownRenderer source={MIXED_SOURCE} />
    </div>
  );
}

Math and HTML Tables

Default math and allowHtml turn $…$ / $$…$$ into KaTeX and render author <table> markup (including rowspan) instead of escaping it. Cell text like $N$ also renders as math. InlineMarkdown shares the math pipeline only — it does not parse raw HTML.

For the long / virtualized knowledge-base path (same pipeline inside scroll segments), see Markdown Document — Math and HTML Tables.

Euler’s identity inline: eiπ+1=0e^{i\pi}+1=0.

Parenthesis delimiters: a2+b2=c2a^2+b^2=c^2.

Attention scales as:

Attention(Q,K,V)=softmax(QKdk)V\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V

Bracket display:

LCE=iyilogy^i\mathcal{L}_{\mathrm{CE}} = -\sum_i y_i \log \hat{y}_i

Parsed artifact table (HTML, not GFM pipes) — cell text like $N$ becomes KaTeX via dollar-lift:

SymbolMeaningTypical size
NNSequence length512512
Batch size (same row-span symbol)3232
dmodeld_{model}Model width768768

For the virtualized knowledge-base path with the same pipeline, open Markdown Document → Math and HTML Tables.

"use client";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

/**
 * MinerU / paper-style fixture: KaTeX in prose plus an HTML `<table>` whose
 * cells contain `$…$` (opaque to remark-math until the dollar-lift pass).
 */
const MATH_HTML_TABLE_SOURCE = [
  "Euler’s identity inline: $e^{i\\pi}+1=0$.",
  "",
  "Parenthesis delimiters: \\(a^2+b^2=c^2\\).",
  "",
  "Attention scales as:",
  "",
  "$$",
  "\\mathrm{Attention}(Q,K,V)=\\mathrm{softmax}\\left(\\frac{QK^{\\top}}{\\sqrt{d_k}}\\right)V",
  "$$",
  "",
  "Bracket display:",
  "",
  "\\[",
  "\\mathcal{L}_{\\mathrm{CE}} = -\\sum_i y_i \\log \\hat{y}_i",
  "\\]",
  "",
  "Parsed artifact table (HTML, not GFM pipes) — cell text like `$N$` becomes KaTeX via dollar-lift:",
  "",
  "<table>",
  "<thead>",
  "<tr><th>Symbol</th><th>Meaning</th><th>Typical size</th></tr>",
  "</thead>",
  "<tbody>",
  '<tr><td rowspan="2">$N$</td><td>Sequence length</td><td>$512$</td></tr>',
  "<tr><td>Batch size (same row-span symbol)</td><td>$32$</td></tr>",
  "<tr><td>$d_{model}$</td><td>Model width</td><td colspan=\"1\">$768$</td></tr>",
  "</tbody>",
  "</table>",
  "",
  "For the **virtualized** knowledge-base path with the same pipeline, open **Markdown Document → Math and HTML Tables**.",
].join("\n");

export function MarkdownRendererMathHtmlTableDemo() {
  return (
    <div className="border-input bg-card max-w-lg overflow-hidden rounded-md border p-4">
      <MarkdownRenderer source={MATH_HTML_TABLE_SOURCE} />
    </div>
  );
}

Allow Images

Images are deny-by-default. Set allowImages to permit any safe http(s) URL, or tighten with allowedImageHosts / allowedImagePrefixes (and allowDataImages for data:image/…). Allowed images use an outline frame by default (subtle border, bitmap flush to the edge). This demo uses a hostname allowlist — compare the allowed picsum.photos image with the blocked example.com placeholder.

Images

Remote images are off by default. Use allowImages for all safe http(s) URLs, or restrict with allowedImageHosts / allowedImagePrefixes. This demo uses a hostname allowlist — picsum.photos renders; example.com stays blocked.

Placeholder

Image blocked: Blocked

"use client"

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"

const SOURCE = [
  "# Images",
  "",
  "Remote images are off by default. Use **`allowImages`** for all safe `http(s)` URLs, or restrict with **`allowedImageHosts`** / **`allowedImagePrefixes`**. This demo uses a hostname allowlist — `picsum.photos` renders; `example.com` stays blocked.",
  "",
  "![Placeholder](https://picsum.photos/seed/f-ui/320/180)",
  "",
  "![Blocked](https://example.com/blocked.png)",
].join("\n")

export function MarkdownRendererAllowedImagesDemo() {
  return (
    <MarkdownRenderer
      source={SOURCE}
      allowedImageHosts={["picsum.photos"]}
    />
  )
}

Image Zoom

Pass imageZoom (with an image allow opt-in) to open the same Plus Document Preview image lightbox on click — shared close control (top-2 right-2), p-0 shell, and zoom / rotate / download toolbar. Dismiss with Escape or the overlay.

"use client"

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"

export function MarkdownRendererImageZoomDemo() {
  return (
    <MarkdownRenderer
      source={"![Demo](https://picsum.photos/seed/zoom/640/360)"}
      allowImages
      imageZoom
    />
  )
}

Streaming Source

Pass isStreaming while characters are still arriving. Incomplete emphasis is repaired mid-stream. This demo also opts into images so a late image line can load.

When Streaming Completes

Leaving isStreaming true after the source stops growing is fine for static display — finished blocks stay memoized. Setting it to false remounts as a single pass (Code Block, Mermaid, and images may reload). Prefer keeping isStreaming true after complete when remounts are undesirable.

Streaming…

"use client";

import { useEffect, useState } from "react";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";
import { Button } from "@/components/ui/button";

const FULL_SOURCE = [
  "# Streaming source",
  "",
  "Incomplete **emphasis** closes only after more tokens arrive.",
  "",
  "![Placeholder](https://picsum.photos/seed/stream/320/180)",
  "",
  "Done.",
].join("\n");

const CHAR_INTERVAL_MS = 28;

export function MarkdownRendererStreamingDemo() {
  const [visibleLength, setVisibleLength] = useState(0);
  const [runId, setRunId] = useState(0);

  useEffect(() => {
    setVisibleLength(0);
    const id = window.setInterval(() => {
      setVisibleLength((prev) => {
        if (prev >= FULL_SOURCE.length) {
          window.clearInterval(id);
          return prev;
        }
        return prev + 1;
      });
    }, CHAR_INTERVAL_MS);
    return () => window.clearInterval(id);
  }, [runId]);

  const source = FULL_SOURCE.slice(0, visibleLength);
  const isStreaming = visibleLength < FULL_SOURCE.length;

  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-3">
        <Button
          type="button"
          variant="outline"
          onClick={() => setRunId((n) => n + 1)}
        >
          Replay
        </Button>
        <p className="text-muted-foreground text-xs">
          {isStreaming ? "Streaming…" : "Complete"}
        </p>
      </div>
      <div className="border-input bg-card min-h-[200px] overflow-auto rounded-md border p-4">
        <MarkdownRenderer source={source} isStreaming={isStreaming} allowImages />
      </div>
    </div>
  );
}

Chat hosts

When rendering a growing assistant reply inside a message list, pass isStreaming={true} until the turn completes so remend and block memoization stay active. See Message Scroller for the shell and the assistant-first recipe; do not wrap long assistant markdown in a solid Bubble by default.

Headless Usage

useMarkdownRenderer is the view-model: resolved className, secure components, and built-in remarkPlugins / rehypePlugins. Spread them onto ReactMarkdown when you own the outer layout or need multiple markdown regions in one tree.

isStreaming is honored by the MarkdownRenderer container only (remend + block split/memo). The headless hook does not preprocess or split — prepare the source yourself, or use the container.

Source length: 121 characters

Headless markdown

Wire useMarkdownRenderer to your own shell and ReactMarkdown.

ts
const ok = true
"use client"

import ReactMarkdown from "react-markdown"

import { useMarkdownRenderer } from "@/components/f-ui/markdown-renderer/use-markdown-renderer"

const SOURCE = [
  "# Headless markdown",
  "",
  "Wire **`useMarkdownRenderer`** to your own shell and **`ReactMarkdown`**.",
  "",
  "```ts",
  "const ok = true",
  "```",
].join("\n")

export function MarkdownRendererHeadlessDemo() {
  const vm = useMarkdownRenderer({ source: SOURCE })

  return (
    <div>
      <p>Source length: {SOURCE.length} characters</p>
      <article>
        <ReactMarkdown
          remarkPlugins={vm.remarkPlugins}
          rehypePlugins={vm.rehypePlugins}
          components={vm.components}
        >
          {SOURCE}
        </ReactMarkdown>
      </article>
    </div>
  )
}

The stock MarkdownRenderer container wraps this hook and adds streaming preprocessing when isStreaming is set.

Composition

MarkdownRenderer                          InlineMarkdown
└── useMarkdownRenderer                   └── createInlineMarkdownComponents
    └── ReactMarkdown                         └── ReactMarkdown
        ├── remark-gfm + remark-math-extended      ├── remark-gfm + remark-math-extended
        ├── rehype: sanitize children → mermaid   ├── rehype: sanitize children
        │   fences → raw → sanitize → dollar-lift │   → dollar-lift → KaTeX
        │   → KaTeX                               │   (no rehype-raw / HTML tables)
        └── full secure element map               └── inline map (a, code, unwrap p)
            ├── Code Block / Mermaid / images
            └── links (link-policy)

Markdown Document (large preview)
└── virtual window of MarkdownDocumentSegment*
        └── MarkdownRenderer   ← same math / allowHtml / GFM / Mermaid / images

Security

ThreatDefault
Raw HTML / <script>Parsed only when allowHtml (default on), then sanitized — script, iframes, and event handlers never become DOM nodes
HTML <img> vs markdown imagesSanitize allows img so markdown ![]() reaches MarkdownImage. That component is the policy gate: HTML <img> cannot bypass allowImages / host / prefix allowlists
javascript: / exotic link schemesLink text only; no <a href>
External http(s) SEOrel="noopener noreferrer nofollow ugc"; set trustedLinks to drop nofollow ugc
Remote images (MarkdownRenderer)Deny-by-default — blocked until allowImages, allowedImageHosts, allowedImagePrefixes, or allowDataImages; blocked/error states keep reserved height
Mermaid source (MarkdownRenderer)Parsed and rendered on the client only; invalid diagrams show an error and switch to code view

InlineMarkdown shares default math but does not parse raw HTML. It also does not wire image or fence chrome — if block/image markdown slips through, default react-markdown <img> can load remote URLs and is not covered by the document deny-by-default policy above.

Override components only when you accept responsibility for the security trade-offs.

API Reference

Props

PropTypeDefaultDescription
sourcestring(required)Markdown string to render.
classNamestringMerged onto the outer <article> (after prose classes).
proseClassNamestringExtra classes merged with prose dark:prose-invert max-w-none.
trustedLinksbooleanfalseWhen true, external links omit nofollow ugc.
mathbooleantrueWhen true, render $…$ / $$…$$ (and \(...\) / \[…\]) with KaTeX. Set false for currency-heavy text.
allowHtmlbooleantrueWhen true, parse embedded HTML (e.g. <table>) then sanitize. When false, HTML tags appear as literal text.
allowImagesbooleanfalseWhen true, allow http: / https: images (dangerous schemes still blocked).
allowedImageHostsreadonly string[]Hostname allowlist (compatibility); matching hosts may render when other allow opts are off.
allowedImagePrefixesreadonly string[]URL prefix allowlist (e.g. https://cdn.example.com/).
allowDataImagesbooleanfalseWhen true, allow data:image/… sources.
imageVariant"outline" | "ghost" | "muted""outline"Frame chrome. Default outline = subtle border; image sits flush (no inner padding). ghost = no border; muted = soft background.
imageObjectFit"contain" | "cover""contain"How the bitmap fills the frame. Default contain shows the full image; cover fills a fixed aspect frame and may crop.
imageZoombooleanfalseWhen true, click a loaded image to open Document Preview (image lightbox).
onImageError(src: string, error: unknown) => voidOptional callback when an allowed image fails to load.
isStreamingbooleanfalseContainer-only: when true, remend incomplete markdown and memoize finished blocks while the source grows. Leaving true after complete is fine; setting false remounts as a single pass. Ignored by useMarkdownRenderer.
defaultMermaidView"code" | "preview""preview"Initial tab for fenced Mermaid blocks.
mermaidThemestringOverrides auto Mermaid theme (light→default, dark→dark).
mermaidConfigMermaidConfigPassed to mermaid.initialize.
onMermaidError(err: unknown) => voidOptional logging hook for diagram failures.
mermaidWheelZoom"always" | "modifier" | "off""modifier"Passed to embedded Mermaid Renderer as wheelZoom. Default keeps page scroll; hold Ctrl or and scroll to zoom the diagram in place.
baseOriginstringOrigin for deciding if a link is external (target / rel).
remarkPluginsPluggableListAppended after remark-gfm.
rehypePluginsPluggableListAppended after built-in rehype plugins.
componentsComponentsShallow-merged over secure defaults.

Hook

useMarkdownRenderer(props) returns { className, components, remarkPlugins, rehypePlugins } — the same inputs MarkdownRenderer passes to ReactMarkdown. The hook accepts shared props (including isStreaming) for typing convenience, but does not remend or split blocks; only the container does.

InlineMarkdown Props

Lightweight phrasing renderer. Same Plus install as MarkdownRenderer. Import from @/components/f-ui/markdown-renderer/inline-markdown.

PropTypeDefaultDescription
sourcestring(required)Markdown string rendered as a fragment.
classNamestringMerged onto the root <span>.
trustedLinksbooleanfalseWhen true, external links omit nofollow ugc.
mathbooleantrueWhen true, render $…$ / $$…$$ with KaTeX (same default as MarkdownRenderer).
baseOriginstringOrigin for deciding if a link is external.
remarkPluginsPluggableListAppended after remark-gfm.
rehypePluginsPluggableListAppended after built-in sanitize.
componentsComponentsShallow-merged over inline defaults (a, inline code, unwrap p).

Not supported on InlineMarkdown: allowHtml, isStreaming, proseClassName, image props, Mermaid props. Use MarkdownRenderer for document layouts, HTML tables, and streaming.

On this page