Markdown Document
Virtualized large-markdown preview that reuses Markdown Renderer — including KaTeX math and sanitized HTML tables.
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-document (pulls markdown-renderer and its Plus deps).
Markdown Document previews long static markdown without mounting the entire DOM. Pass a single source (auto-chunked) or host segments; only a virtual window renders. Each mounted segment is a Markdown Renderer — so you get the same GFM, KaTeX ($…$ / $$…$$), sanitized HTML <table>, Code Block, Mermaid, and link/image policy by default. Document does not reimplement that pipeline; it only virtualizes.
When To Use
- Preview a large static markdown export (knowledge base article, OCR / MinerU dump with formulas and HTML tables, generated report) inside a fixed-height panel.
- You already own section boundaries and can pass
{ id, source }[]— chapters, API slices, or OCR page→segment mapping in the host. - You need
scrollToSegment/ visible-id callbacks for host TOC or progress chrome without baking product navigation into f-ui. - Prefer Markdown Renderer for small documents, streaming replies, or when every block must stay mounted (browser Ctrl+F over the full text).
- Do not use this for live token streaming —
isStreamingis not supported; stream with Markdown Renderer until the document is complete, then switch if needed.
Features
| Area | Behavior |
|---|---|
| Auto-chunk | source splits near chunkChars (default ~140KB), preferring blank lines; never breaks inside an open fence |
| Host segments | When segments is set, it takes precedence over source (including empty [] → empty state) |
| Virtual window | Internal scroll root with thin native scrollbar (aligned with MessageScroller / markdown tables); spacers + measured heights; overscan defaults to ~50% of viewport height |
| Shared rendering | Each mounted segment is Markdown Renderer — default math / allowHtml, GFM, Code Block, Mermaid, image policy |
| Forwarded props | math, allowHtml, image / Mermaid / plugin props pass through (everything except source, isStreaming, className) |
| Navigation | Ref handle: scrollToSegment, getSegmentCount; optional onVisibleSegmentIdsChange |
| Empty | Explicit empty status when there is no markdown to show — not a blank panel |
Shared with Markdown Renderer
Math, HTML tables, sanitize, and image gates live on Markdown Renderer. Document only measures and virtualizes segments. Toggle math={false} or allowHtml={false} on <MarkdownDocument> the same way you would on <MarkdownRenderer>.
Tailwind Typography
Visible segments render through Markdown Renderer, which applies prose / dark:prose-invert. Install @tailwindcss/typography as on the Markdown Renderer page.
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-documentFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/markdown-documentFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/markdown-documentFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/markdown-documentregistryDependencies: @f-ui-plus/markdown-renderer. Runtime deps come from that install (react-markdown, remark-gfm, remark-math-extended, rehype-katex, katex, rehype-raw, rehype-sanitize, shiki, mermaid, …).
Usage
import { MarkdownDocument } from "@/components/f-ui/markdown-document/markdown-document";
export function Page({ markdown }: { markdown: string }) {
return (
<MarkdownDocument
source={markdown}
className="h-[70vh]"
allowImages
allowedImageHosts={["cdn.example.com"]}
// math + allowHtml default true — same as Markdown Renderer
/>
);
}Examples
Auto-Chunked Source
Pass one markdown string with a smaller chunkChars so you can see multiple virtualized segments. Scroll the fixed-height panel — off-screen segments unmount while spacers preserve scroll height.
Auto-chunked document
This demo passes a single source string with chunkChars={2000} so the document splits into multiple virtualized segments. Scroll the panel — only a window of segments stays mounted.
Section 1
Paragraph 1 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
More prose for section 1: lists, emphasis, and links stay in the same security model as Markdown Renderer.
- Item A-1
- Item B-1
- Item C-1
Section 2
Paragraph 2 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
More prose for section 2: lists, emphasis, and links stay in the same security model as Markdown Renderer.
- Item A-2
- Item B-2
- Item C-2
Section 3
Paragraph 3 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
More prose for section 3: lists, emphasis, and links stay in the same security model as Markdown Renderer.
- Item A-3
- Item B-3
- Item C-3
Section 4
Paragraph 4 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
More prose for section 4: lists, emphasis, and links stay in the same security model as Markdown Renderer.
- Item A-4
- Item B-4
- Item C-4
Section 5
Paragraph 5 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
More prose for section 5: lists, emphasis, and links stay in the same security model as Markdown Renderer.
- Item A-5
- Item B-5
- Item C-5
Section 6
Paragraph 6 expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.
"use client"
import { MarkdownDocument } from "@/components/f-ui/markdown-document/markdown-document"
function buildDemoSource(): string {
const sections: string[] = [
"# Auto-chunked document",
"",
"This demo passes a single **`source`** string with **`chunkChars={2000}`** so the document splits into multiple virtualized segments. Scroll the panel — only a window of segments stays mounted.",
"",
]
for (let i = 1; i <= 24; i++) {
sections.push(`## Section ${i}`)
sections.push("")
sections.push(
`Paragraph ${i} expands the source past the chunk budget. Blank lines are preferred break points; fenced code stays intact across chunks.`,
)
sections.push("")
sections.push("```ts")
// Avoid the literal "export const" — remarkDemoWrapper scans demos with a
// naive /export (?:function|const) Name/ and would bind the wrong export.
sections.push(`const section${i} = ${i}`)
sections.push("```")
sections.push("")
sections.push(
`More prose for section ${i}: lists, emphasis, and links stay in the same security model as **Markdown Renderer**.`,
)
sections.push("")
sections.push(`- Item A-${i}`)
sections.push(`- Item B-${i}`)
sections.push(`- Item C-${i}`)
sections.push("")
}
return sections.join("\n")
}
const SOURCE = buildDemoSource()
export function MarkdownDocumentDemo() {
return (
<div className="border-input bg-card h-[420px] overflow-hidden rounded-md border">
<MarkdownDocument
source={SOURCE}
chunkChars={2000}
className="h-full p-4"
/>
</div>
)
}Math and HTML Tables
Same defaults as Markdown Renderer — Math and HTML Tables — Document does not ship a second engine. You get:
- KaTeX for
$…$/$$…$$and\(...\)/\[…\] - Sanitized author
<table>(incl.rowspan) instead of escaped source $N$-style math inside HTML cells (dollar-lift after sanitize)- The same escape hatches:
math={false},allowHtml={false}on<MarkdownDocument>
The fixture is long enough to virtualize — scroll the panel; formulas and the mid-document HTML table still render when segments remount. This is the knowledge-base / MinerU preview path.
Parsed paper preview
This Markdown Document panel uses the same Markdown Renderer pipeline as the short-form demo: default math and allowHtml. Scroll — off-screen segments unmount; KaTeX and HTML tables stay in the mounted window.
Euler and attention
Inline identity: .
Parenthesis form also works: .
Block attention:
Display with brackets:
Transformer variants (HTML table)
Author HTML (not GFM pipes) with rowspan and cell math — the kind of dump OCR / MinerU often emits:
| Group | Symbol | Meaning | Typical |
|---|---|---|---|
| (A) | Sequence length | ||
| Model width | |||
| (B) | Feed-forward width | ||
| (C) | Attention heads |
Escape hatches on this shell are the same forwarded props: math={false} or allowHtml={false}.
Filler section 1
Padding so the document virtualizes. Section 1 keeps KaTeX available when remounted: , .
Filler section 2
Padding so the document virtualizes. Section 2 keeps KaTeX available when remounted: , .
Filler section 3
Padding so the document virtualizes. Section 3 keeps KaTeX available when remounted: , .
Filler section 4
"use client"
import { MarkdownDocument } from "@/components/f-ui/markdown-document/markdown-document"
/**
* Long KB / MinerU-style fixture: KaTeX + HTML tables ride Markdown Renderer
* inside each virtualized segment — same defaults as the Renderer page demo,
* but inside a scroll panel so integrators see Document inherits the pipeline.
*/
function buildMathHtmlDocumentSource(): string {
const parts: string[] = [
"# Parsed paper preview",
"",
"This **Markdown Document** panel uses the same **Markdown Renderer** pipeline as the short-form demo: default **`math`** and **`allowHtml`**. Scroll — off-screen segments unmount; KaTeX and HTML tables stay in the mounted window.",
"",
"## Euler and attention",
"",
"Inline identity: $e^{i\\pi}+1=0$.",
"",
"Parenthesis form also works: \\(\\mathrm{softmax}(x)_i = \\frac{e^{x_i}}{\\sum_j e^{x_j}}\\).",
"",
"Block attention:",
"",
"$$",
"\\mathrm{Attention}(Q,K,V)=\\mathrm{softmax}\\left(\\frac{QK^{\\top}}{\\sqrt{d_k}}\\right)V",
"$$",
"",
"Display with brackets:",
"",
"\\[",
"\\mathcal{L} = -\\sum_i y_i \\log \\hat{y}_i",
"\\]",
"",
"## Transformer variants (HTML table)",
"",
"Author HTML (not GFM pipes) with **`rowspan`** and cell math — the kind of dump OCR / MinerU often emits:",
"",
"<table>",
"<thead>",
"<tr><th>Group</th><th>Symbol</th><th>Meaning</th><th>Typical</th></tr>",
"</thead>",
"<tbody>",
'<tr><td rowspan="2">(A)</td><td>$N$</td><td>Sequence length</td><td>$512$</td></tr>',
"<tr><td>$d_{\\mathrm{model}}$</td><td>Model width</td><td>$768$</td></tr>",
"<tr><td>(B)</td><td>$d_{\\mathrm{ff}}$</td><td>Feed-forward width</td><td>$2048$</td></tr>",
"<tr><td>(C)</td><td>$h$</td><td>Attention heads</td><td>$12$</td></tr>",
"</tbody>",
"</table>",
"",
"Escape hatches on this shell are the same forwarded props: **`math={false}`** or **`allowHtml={false}`**.",
"",
]
for (let i = 1; i <= 16; i++) {
parts.push(`## Filler section ${i}`)
parts.push("")
parts.push(
`Padding so the document virtualizes. Section ${i} keeps KaTeX available when remounted: $i=${i}$, $i^2=${i * i}$.`,
)
parts.push("")
if (i === 8) {
parts.push("Mid-document HTML table (still rendered after remount):")
parts.push("")
parts.push("<table>")
parts.push("<thead><tr><th>Metric</th><th>Value</th></tr></thead>")
parts.push("<tbody>")
parts.push("<tr><td>Perplexity</td><td>$e^{H}$</td></tr>")
parts.push("<tr><td>Temperature</td><td>$T=0.7$</td></tr>")
parts.push("</tbody>")
parts.push("</table>")
parts.push("")
}
parts.push("```ts")
parts.push(`const filler${i} = ${i}`)
parts.push("```")
parts.push("")
}
return parts.join("\n")
}
const SOURCE = buildMathHtmlDocumentSource()
export function MarkdownDocumentMathHtmlTableDemo() {
return (
<div className="border-input bg-card h-[420px] overflow-hidden rounded-md border">
<MarkdownDocument
source={SOURCE}
chunkChars={1800}
className="h-full p-4"
/>
</div>
)
}Host-Defined Segments
When the host already knows section boundaries, pass segments. Jump buttons call scrollToSegment; the visible-id line is driven by onVisibleSegmentIdsChange. OCR or pagination pipelines can map each page export to a segment in the host — the public API stays segment-agnostic.
Visible: —
Host-defined segments
Pass segments when you already own section boundaries — chapters, API slices, or OCR page exports mapped to { id, source } in the host.
The component never interprets what a segment means; it only virtualizes and renders.
"use client"
import { useRef, useState } from "react"
import {
MarkdownDocument,
type MarkdownDocumentHandle,
type MarkdownSegment,
} from "@/components/f-ui/markdown-document/markdown-document"
import { Button } from "@/components/ui/button"
const SEGMENTS: readonly MarkdownSegment[] = [
{
id: "intro",
source: [
"# Host-defined segments",
"",
"Pass **`segments`** when you already own section boundaries — chapters, API slices, or OCR page exports mapped to `{ id, source }` in the host.",
"",
"The component never interprets what a segment means; it only virtualizes and renders.",
].join("\n"),
},
{
id: "setup",
source: [
"## Setup",
"",
"Install **Markdown Document**, then forward the same security props you use on **Markdown Renderer** (images, zoom, Mermaid).",
"",
"```bash",
"pnpm dlx shadcn@latest add @f-ui-plus/markdown-document",
"```",
].join("\n"),
},
{
id: "navigation",
source: [
"## Navigation",
"",
"Use the imperative handle to jump by segment id. Host chrome (TOC, progress) stays outside the component.",
"",
"| API | Role |",
"| --- | --- |",
"| `scrollToSegment` | Scroll + force-mount target |",
"| `getSegmentCount` | Total segments |",
"| `onVisibleSegmentIdsChange` | Drive host chrome |",
].join("\n"),
},
{
id: "closing",
source: [
"## Closing",
"",
"Prefer host **`segments`** when boundaries matter. Prefer auto-chunked **`source`** for unstructured exports.",
].join("\n"),
},
]
export function MarkdownDocumentSegmentsDemo() {
const ref = useRef<MarkdownDocumentHandle>(null)
const [visibleIds, setVisibleIds] = useState<string[]>([])
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{SEGMENTS.map((segment) => (
<Button
key={segment.id}
type="button"
variant="outline"
onClick={() => ref.current?.scrollToSegment(segment.id)}
>
Jump to {segment.id}
</Button>
))}
<p className="text-muted-foreground text-xs">
Visible: {visibleIds.length ? visibleIds.join(", ") : "—"}
</p>
</div>
<div className="border-input bg-card h-[360px] overflow-hidden rounded-md border">
<MarkdownDocument
ref={ref}
segments={SEGMENTS}
className="h-full p-4"
onVisibleSegmentIdsChange={setVisibleIds}
/>
</div>
</div>
)
}Headless Usage
useMarkdownDocument is the view-model: resolved segments, visible index range, spacer heights, reportHeight, and the same scroll helpers exposed on the container handle. Spread them onto your own scroll root and segment chrome — stock triggers are optional.
segmentCount: 3
visibleIds: chunk-0
Headless markdown document
useMarkdownDocument is the view-model: resolved segments, visible window, spacers, and scroll helpers.
Second section
Scroll the native viewport below. Readout lines show segment count and visible ids — no stock chrome.
"use client"
import { useEffect, useRef } from "react"
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer"
import { useMarkdownDocument } from "@/components/f-ui/markdown-document/use-markdown-document"
const SOURCE = [
"# Headless markdown document",
"",
"**`useMarkdownDocument`** is the view-model: resolved segments, visible window, spacers, and scroll helpers.",
"",
"## Second section",
"",
"Scroll the native viewport below. Readout lines show segment count and visible ids — no stock chrome.",
"",
"```ts",
"const ok = true",
"```",
"",
"## Third section",
"",
"Compose your own shell; render each visible segment with **Markdown Renderer** (or any markdown surface).",
"",
"More lines keep the window interesting while overscan mounts neighbors.",
"",
"## Fourth section",
"",
"Jump buttons call **`scrollToSegment`** from the same hook return.",
].join("\n")
function MeasuredSegment({
id,
source,
onHeight,
}: {
id: string
source: string
onHeight: (id: string, heightPx: number) => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const report = () => onHeight(id, el.offsetHeight)
report()
const ro = new ResizeObserver(report)
ro.observe(el)
return () => ro.disconnect()
}, [id, source, onHeight])
return (
<div ref={ref} data-segment-id={id}>
<MarkdownRenderer source={source} />
</div>
)
}
export function MarkdownDocumentHeadlessDemo() {
const doc = useMarkdownDocument({
source: SOURCE,
chunkChars: 280,
})
const visibleIds: string[] = []
if (!doc.isEmpty && doc.visibleStart <= doc.visibleEnd) {
for (let i = doc.visibleStart; i <= doc.visibleEnd; i++) {
visibleIds.push(doc.segments[i]!.id)
}
}
const visible: typeof doc.segments = []
if (!doc.isEmpty && doc.visibleStart <= doc.visibleEnd) {
for (let i = doc.visibleStart; i <= doc.visibleEnd; i++) {
visible.push(doc.segments[i]!)
}
}
return (
<div>
<p>segmentCount: {doc.getSegmentCount()}</p>
<p>visibleIds: {visibleIds.length ? visibleIds.join(", ") : "(none)"}</p>
<p>
<button
type="button"
onClick={() => {
const last = doc.segments[doc.segments.length - 1]
if (last) doc.scrollToSegment(last.id)
}}
>
scrollToSegment(last)
</button>{" "}
<button
type="button"
onClick={() => {
const first = doc.segments[0]
if (first) doc.scrollToSegment(first.id)
}}
>
scrollToSegment(first)
</button>
</p>
{doc.isEmpty ? (
<p>No content</p>
) : (
<div
ref={doc.scrollRootRef}
style={{ height: 240, overflow: "auto" }}
>
{doc.spacerTop > 0 ? (
<div aria-hidden style={{ height: doc.spacerTop }} />
) : null}
{visible.map((segment) => (
<MeasuredSegment
key={segment.id}
id={segment.id}
source={segment.source}
onHeight={doc.reportHeight}
/>
))}
{doc.spacerBottom > 0 ? (
<div aria-hidden style={{ height: doc.spacerBottom }} />
) : null}
</div>
)}
</div>
)
}The stock MarkdownDocument container wires this hook to MarkdownDocumentSegment (measured Markdown Renderer wrappers) and the empty state.
Composition
MarkdownDocument
└── useMarkdownDocument
├── resolveMarkdownSegments (source | segments, chunkChars)
├── virtual window (offsets, overscan, spacers)
└── visible MarkdownDocumentSegment*
└── MarkdownRenderer ← math / allowHtml / GFM / Mermaid / images
├── remark-gfm + remark-math-extended
└── rehype: sanitize children → mermaid fences → raw → sanitize
→ dollar-lift → KaTeXEdge Cases & Errors
Browser Find (Ctrl+F)
Only mounted segment DOM is searchable. Off-window content is spacer placeholders, so in-document find will miss unloaded text. Prefer host search over raw segment strings, keep documents small enough to use Markdown Renderer, or wait for a future find API.
Awkward Auto-Chunk Boundaries
Auto-chunk prefers blank lines and refuses to split open fences, but nested lists, tables, or reference links can still split awkwardly in pathological sources. Pass host segments when boundaries matter.
Empty Input
Missing source, empty string after chunking, or segments={[]} shows the built-in empty status (headline + short why) — never a blank panel. Hosts that fetch asynchronously own Loading / Error outside this component.
This demo uses segments={[]} (same empty UI as an empty source).
No content
Provide markdown source or segments to preview.
"use client"
import { MarkdownDocument } from "@/components/f-ui/markdown-document/markdown-document"
export function MarkdownDocumentEmptyDemo() {
return (
<div className="border-input bg-card h-[220px] overflow-hidden rounded-md border">
<MarkdownDocument segments={[]} className="h-full" />
</div>
)
}API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | — | Unstructured markdown. Auto-chunked when segments is omitted. Ignored when segments is provided. |
segments | readonly MarkdownSegment[] | — | Host-owned { id, source } list. When set (including []), takes precedence over source. |
chunkChars | number | 140000 | Soft character target for auto-chunking source. |
overscanPx | number | ~0.5 × viewport | Extra pixels kept mounted beyond the visible range. When omitted, uses half the scrollport height. |
className | string | — | Merged onto the scroll root (or empty-state wrapper). Scroll root defaults to a thin native scrollbar; override via className. |
onVisibleSegmentIdsChange | (ids: string[]) => void | — | Fires when the mounted visible id set changes (for host TOC / progress). |
ref | Ref<MarkdownDocumentHandle> | — | Imperative scrollToSegment / getSegmentCount. |
math | boolean | true | Forwarded to each segment Markdown Renderer — KaTeX for $…$ / $$…$$. |
allowHtml | boolean | true | Forwarded — sanitized author HTML (e.g. <table>) in segments. |
…rendererProps | forwarded | — | Other shared Markdown Renderer props except source, isStreaming, and className (images, zoom, Mermaid, plugins, …). Full list: Markdown Renderer API. |
MarkdownSegment: { id: string; source: string }.
Hook
useMarkdownDocument(options) returns { scrollRootRef, isEmpty, segments, visibleStart, visibleEnd, spacerTop, spacerBottom, reportHeight, scrollToSegment, getSegmentCount, handle }. Use it when you own the scroll shell; call reportHeight after each mounted segment measures.
Handle
| Method | Description |
|---|---|
scrollToSegment(id) | Scroll so the segment is in view; force-mounts it if needed. |
getSegmentCount() | Auto-chunk or host segment count. |