f-ui
Components

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 — isStreaming is not supported; stream with Markdown Renderer until the document is complete, then switch if needed.

Features

AreaBehavior
Auto-chunksource splits near chunkChars (default ~140KB), preferring blank lines; never breaks inside an open fence
Host segmentsWhen segments is set, it takes precedence over source (including empty [] → empty state)
Virtual windowInternal scroll root with thin native scrollbar (aligned with MessageScroller / markdown tables); spacers + measured heights; overscan defaults to ~50% of viewport height
Shared renderingEach mounted segment is Markdown Renderer — default math / allowHtml, GFM, Code Block, Mermaid, image policy
Forwarded propsmath, allowHtml, image / Mermaid / plugin props pass through (everything except source, isStreaming, className)
NavigationRef handle: scrollToSegment, getSegmentCount; optional onVisibleSegmentIdsChange
EmptyExplicit 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-document
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/markdown-document
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/markdown-document
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/markdown-document

registryDependencies: @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.

ts
const section1 = 1

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.

ts
const section2 = 2

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.

ts
const section3 = 3

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.

ts
const section4 = 4

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.

ts
const section5 = 5

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: eiπ+1=0e^{i\pi}+1=0.

Parenthesis form also works: softmax(x)i=exijexj\mathrm{softmax}(x)_i = \frac{e^{x_i}}{\sum_j e^{x_j}}.

Block attention:

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

Display with brackets:

L=iyilogy^i\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:

GroupSymbolMeaningTypical
(A)NNSequence length512512
dmodeld_{\mathrm{model}}Model width768768
(B)dffd_{\mathrm{ff}}Feed-forward width20482048
(C)hhAttention heads1212

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: i=1i=1, i2=1i^2=1.

ts
const filler1 = 1

Filler section 2

Padding so the document virtualizes. Section 2 keeps KaTeX available when remounted: i=2i=2, i2=4i^2=4.

ts
const filler2 = 2

Filler section 3

Padding so the document virtualizes. Section 3 keeps KaTeX available when remounted: i=3i=3, i2=9i^2=9.

ts
const filler3 = 3

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 → KaTeX

Edge 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

PropTypeDefaultDescription
sourcestringUnstructured markdown. Auto-chunked when segments is omitted. Ignored when segments is provided.
segmentsreadonly MarkdownSegment[]Host-owned { id, source } list. When set (including []), takes precedence over source.
chunkCharsnumber140000Soft character target for auto-chunking source.
overscanPxnumber~0.5 × viewportExtra pixels kept mounted beyond the visible range. When omitted, uses half the scrollport height.
classNamestringMerged onto the scroll root (or empty-state wrapper). Scroll root defaults to a thin native scrollbar; override via className.
onVisibleSegmentIdsChange(ids: string[]) => voidFires when the mounted visible id set changes (for host TOC / progress).
refRef<MarkdownDocumentHandle>Imperative scrollToSegment / getSegmentCount.
mathbooleantrueForwarded to each segment Markdown Renderer — KaTeX for $…$ / $$…$$.
allowHtmlbooleantrueForwarded — sanitized author HTML (e.g. <table>) in segments.
…rendererPropsforwardedOther 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

MethodDescription
scrollToSegment(id)Scroll so the segment is in view; force-mounts it if needed.
getSegmentCount()Auto-chunk or host segment count.

On this page