Skip to content
Component installs need the registry setup
VegaStack Design

Markdown View

Render a markdown string to safe, token-styled HTML — headings, lists, code, blockquotes, links, and GFM tables — XSS-safe with no raw HTML.

Status
stable
Since
0.1.0
Accessibility pattern
semantic HTML document

Last updated

Deployment summary

Render a markdown string to safe, token-styled HTML. The agent wrote this changelog (opens in new tab) using inline code and the blocks below.

What changed

  • Migrated tokens to the warm-neutral ramp
  • Rationed colour to a neutral primary and info
  • Flattened surfaces to a single border
TypeScript
function deploy(target: string) {
  return `Deploying to ${target}…`;
}

Links use the info blue, headings settle at weight 500 — never bold.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Markdown View before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify markdown-view

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/markdown-view

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/checkbox, @vegastack/code-block, @vegastack/inline-chip, @vegastack/button, @vegastack/popover, @vegastack/alert, @vegastack/text-edit-grammar, @vegastack/toast, @vegastack/use-announcer, @vegastack/text-anchor, @vegastack/embed-providers, @vegastack/block-view, @vegastack/task-inline, @vegastack/skeleton.

It also adds the sanctioned engine to your package.json: marked (markdown lexer engine).

Usage

Image URLs are normalized against a fixed origin before policy checks; relative images remain local and remote images require an allowed HTTP(S) origin.

import { MarkdownView } from "@/components/ui/markdown-view";

<MarkdownView>{`# Hello\n\nThis is **markdown**.`}</MarkdownView>;

Pass the markdown as children (a string) or via the content prop when the source comes from a data field:

<MarkdownView content={task.description} />

MarkdownView tokenizes with marked's lexer (CommonMark + GitHub-flavored markdown: tables, strikethrough, task lists, autolinks — the same parser TextEdit's Markdown format uses) and rebuilds the tokens as React elements — never dangerouslySetInnerHTML — so any raw HTML in the source renders as inert text. It is server-safe (no 'use client'), light enough for a page's first bundle (one ~13 KB parser), and renders nothing for empty/whitespace input.

Relative image paths render normally. Absolute and protocol-relative remote images are blocked by default so untrusted Markdown cannot create tracking requests. When a trusted media host is required, allow its exact origin; allowed remote images send no referrer:

["*"] allows every HTTP(S) origin, for content whose author is trusted to embed images — TextEdit's read view passes it, matching the editor, which shows any image.

<MarkdownView allowedImageOrigins={["https://media.example.com"]}>
  {"![Product preview](https://media.example.com/preview.png)"}
</MarkdownView>

An image's width set by resizing it in TextEdit travels in its alt text — ![Diagram|320](src) renders 320px wide (never wider than the text column) with the alt "Diagram"; ![Diagram|320x180](src) also gives its height, so its box is reserved before it loads (TextEdit stores the size of every image it inserts). parseImageAlt and imageMarkdown (from @/lib/text-edit-grammar) read and write that form. An image on a line of its own is a block — a figure, its title the caption — laid out as TextEdit lays it out, so the read view and the editor match. To open images in the FileViewer on a click, wrap the view in TextEdit's ImageViewerScope.

Anatomy

MarkdownView — data-slot="markdown-view"
MentionChip
MarkdownLiveBlock — data-slot="markdown-live-block" | "markdown-live-block-row"
MarkdownCitationMarker — data-slot="markdown-citation" | "markdown-citation-content" | "markdown-citation-trigger"
MarkdownHeadingLink — data-slot="markdown-heading-link"
MarkdownTableScroll — data-slot="markdown-table"
MarkdownViewEffects — data-slot="markdown-find-bar" | "markdown-find-count"
MarkdownBlockView — data-slot="block-view" | "block-view-fallback"
MarkdownEmbed — data-slot="markdown-embed" | "markdown-embed-frame" | "markdown-embed-link" | "markdown-embed-url"
MarkdownSectionMarker — data-slot="markdown-section-mark"
MarkdownBookmark — data-slot="markdown-bookmark" | "markdown-bookmark-address" | "markdown-bookmark-description" | "markdown-bookmark-image" | "markdown-bookmark-text" | "markdown-bookmark-title"
MarkdownTaskInline

Examples

Default

Headings, paragraphs, inline code, links, lists, and blockquotes — styled by the shared prose recipe from @vegastack/design, worn as one class on the root (no @tailwindcss/typography dependency). TextEdit wears the same string, so rendered markdown and edited rich text are the same typography rather than two that agree by review.

Deployment summary

Render a markdown string to safe, token-styled HTML. The agent wrote this changelog (opens in new tab) using inline code and the blocks below.

What changed

  • Migrated tokens to the warm-neutral ramp
  • Rationed colour to a neutral primary and info
  • Flattened surfaces to a single border
TypeScript
function deploy(target: string) {
  return `Deploying to ${target}…`;
}

Links use the info blue, headings settle at weight 500 — never bold.

GitHub-flavored markdown

GitHub-flavored markdown adds tables, ~~strikethrough~~, and - [ ] task lists. Task-list checkboxes are rendered read-only (display only). A table is Notion's simple table — a rounded outer border, a rule between every cell and a header row on a muted ground at medium weight — the exact styles TextEdit edits it in, scrolling in its own box when wider than the page.

GitHub-flavored markdown

A table, strikethrough, and a task list:

FeatureStatus
TablesDone
Task listsDone
  • Render tables
  • Render task lists
  • Add raw HTML (never)

Code blocks

Inline code renders as a muted font-mono chip; fenced code blocks delegate to CodeBlock — a <figure> with a language caption and a copy action — rather than a bare <pre>. The language comes from the fence's language-* class and the copy source from the code text, so a transcript, a docs page and a hand-composed example all share one code surface.

Code blocks

Inline const x = 1 and a fenced block:

TypeScript
function greet(name: string) {
  return `Hello, ${name}`;
}

External links (those starting with http(s)://) open in a new tab with target="_blank" and rel="noreferrer noopener"; relative/internal links stay in the same tab. The link color (text-info-text) and underline are identical in both cases.

Links adapt to their destination:

Full element set

A consolidated sample exercising every styled element — all six heading levels, ordered, unordered and nested task lists, a quote, fenced code with a language, a GFM table, a horizontal rule, an image, inline emphasis and a long URL — so the complete prose surface renders in one view. It is everything TextEdit writes, in the same body typography and at the same positions. Nothing widens the page: long words and URLs wrap, and code blocks and tables scroll inside their own box.

Heading 1

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

A paragraph with bold, italic, strikethrough, and inline code.

  1. First ordered step
  2. Second ordered step
  3. Third ordered step
  • Unordered item

  • Another item

    • Nested item
  • Done task

  • Open task

    • Nested task

A blockquote settles at the muted foreground.

Python
print("fenced code keeps its language")
OwnerTask
AdaRelease notes
GracePricing page

A long URL wraps inside the line: https://example.com/a/very/long/path/that/would/otherwise/widen/the/page/and/scroll/it/sideways (opens in new tab)


A scenic landscape

Markdown usually starts its outline at #. When it renders under the page's own headings — a card titled with an h2 — pass headingOffset to move every heading down that many levels, capped at h6: headingOffset of 2 renders # as an h3, so the outline never skips back up. Each heading takes the prose style of the level it lands on.

<MarkdownView headingOffset={2}>{notes}</MarkdownView>

HTML source

format="html" renders HTML instead of markdown — TextEdit's format="html" output, which is how TextEdit shows a saved document before it is first edited. The HTML is rebuilt through a tag and attribute allowlist (the elements rich text uses: headings, paragraphs, marks, lists and task lists, quotes, code, tables, links, images), never injected: scripts, styles, event handlers and unsafe URLs are dropped, and the same link, image, code-block and task-list rules apply.

Release checklist

Ship the lazy editor and tell the team (opens in new tab).

  • Read view renders on the server

  • Editor loads on intent

TypeScript
const editor = await import("./text-edit-editor");
A scenic landscape
<MarkdownView format="html" content={note.html} />

Mentions, files, callouts and toggles

The constructs TextEdit's Markdown carries beyond GFM render here too, still server-safe:

  • Mentions — [@Asha Rao](mention://user/u1) renders as an InlineChip: a person on blue with their avatar, any other kind on the neutral chip with its kind's icon, then the name; mentionImage(id) gives a person chip their photo. mentionHref(kind, id) makes a chip a link (return null for none); a person is never a link, and an id starting with restricted: renders a muted chip that never links (the target is one the reader may not open). mentionStatus(kind, id) returning { struck: true, status: "Canceled" } strikes a closed target's label and tags its status, as in TextEdit. Any kind matching [a-z][a-z0-9_]{1,31} is a mention: a kind the app names in the InlineChipProvider's kinds ([@Glazing kit](mention://product/v1)) shows its icon, any other a link glyph.
  • File chips — a link whose href starts with fileLinkPrefix (default /api/files/) renders as a chip with an icon for the file's type; "" turns file chips off.
  • Callouts — GitHub's alert syntax, > [!NOTE], > [!TIP] and > [!WARNING], renders as a role="note" box in the tone's ink.
  • Toggles — <details><summary>Title</summary>, a blank line, the body, a blank line and </details> render as a native disclosure, collapsed. <br> is a line break (in a table cell, a heading or a sentence); any other raw HTML still renders as text.
  • Heading ids — headingIds gives every heading the id TextEdit's onOutlineChange reports (its words, hyphenated; -1, -2… for a repeat), so an outline links into either. headingIds(texts) (from @/lib/text-edit-grammar) builds the same ids on a server.

Kitchen circuit

Ask Asha Rao before changing Breaker sizing. The layout is in panel-layout.pdf; Private page stays muted.

The panel is labelled left to right.

Use a 25 A breaker for the kitchen circuit.

Isolate the supply before opening the panel.

Wiring colours
  • Brown: live
  • Blue: neutral

Kitchen circuit

<MarkdownView
  headingIds
  mentionHref={(kind, id) => (kind === "page" ? `/library/${id}` : null)}
>
  {page.body}
</MarkdownView>

parseMentionLink(source) reads a mention at the start of a string and mentionMarkdown(kind, id, label) writes one exactly as TextEdit does (the label escapes \, [ and ]). Both come from @/lib/text-edit-grammar, which MarkdownView installs with it.

Embeds

A YouTube or Loom link alone on its line (a bare URL, <url> or [url](url), in the document, a quote, a callout or a toggle) renders, with embeds={EMBED_PROVIDERS} (opt-in; without it such a line is a plain link), as TextEdit's embed facade, MarkdownEmbed: a 16:9 card with the provider's glyph, "YouTube video" or "Loom video", and Play. The markup is server-rendered and nothing loads from the provider until Play, which swaps in a lazy, sandboxed player frame whose src is rebuilt from the video id — never the URL as written. Printed, it is the URL. A link in a sentence, in a list item, or to any other host stays a link. TextEdit's HTML for an embed (div[data-type="embed"] around its link) renders the same way. The allowlist and the frame-src origins come from @/lib/embed-providers, which MarkdownView installs with it: matchEmbed(url) returns { provider, id, start?, src } or null, and EMBED_FRAME_ORIGINS is the Content-Security-Policy frame-src list.

The walkthrough:

YouTube video

A link in a sentence stays a link: https://youtu.be/aqz-KE-bpKQ (opens in new tab).

Bookmarks and task lines

A link titled bookmark alone on its line — [https://example.com](https://example.com "bookmark"), what TextEdit's Bookmark writes — renders as a card (MarkdownBookmark): the page's title, its blurb and its icon with the site's address, from the InlineChipProvider's linkPreview (or a fetchPreview / preview given to MarkdownBookmark itself). It loads nothing from the site and opens the page in a new tab. Any other reader shows the link.

onTaskOpen draws a linked checklist line (- [ ] [@Order glass](mention://task/t1)) as a TaskInline: one round box, the title that calls onTaskOpen(taskId) on a click, the assignee's avatar and the due date from mentionStatus (assignee, due, overdue) or the provider's state, and ↗ to its page on hover.

PropTypeDefaultDescription
url*string—The page's address (http or https).
fetchPreview((url: string, options: { signal: AbortSignal; }) => Promise<MarkdownBookmarkPreview | null>)the provider's `linkPreview`Asks the host for the page's preview (its server reads the page, so the reader's browser never contacts the site), once per URL. Without it, the InlineChipProvider's linkPreview.
previewInlineChipLinkPreview | null—The preview, when the host already has it: shown as given, nothing is asked.

Data attributes and CSS variables on MarkdownBookmark

AttributeValues
data-anchor-block""
data-anchor-textmirrors a prop or state value
data-loading""
data-slot"markdown-bookmark" | "markdown-bookmark-address" | "markdown-bookmark-description" | "markdown-bookmark-image" | "markdown-bookmark-text" | "markdown-bookmark-title"

Live task lists

A live task list is a container directive: :::tasks{…} at the start of a line, its rows, and ::: alone on a line.

:::tasks{id="lb_9f2c" v="1" space="page" assignee="me" status="open" sort="due" limit="25" as-of="2026-10-14T09:00:00Z"}
- [ ] [@Order glass for block B](mention://task/0192…) — To do
- [x] [@Fix scaffolding](mention://task/0193…) — Done
:::

The attributes are key="value" pairs separated by spaces. Keys are [a-z][a-z0-9-]*, values are double-quoted, and \" and \\ are the escapes. id is the block's id. The rest are the list's filter, and unknown keys are kept as written. Each row is a task item whose first inline is a task mention, then an em dash between spaces and the status label. MarkdownView renders the rows as a quiet, read-only card (MarkdownLiveBlock): the muted heading "Task list", then each row's done box, task chip and status label. A list with no rows shows "No matching tasks". The card is the snapshot stored with the page, so a public view never gets live data.

Lines that do not make a whole block read as Markdown always did. That includes a missing closing line, a row that is not a list item line, or attributes that do not parse. A renderer without directive support shows the same lines as text and a list. TextEdit's HTML for a list (div[data-type="task-query"]) renders the same card.

renderTaskQuery({ blockId, query, snapshot, editable }) lets a host draw the live list in its place, with editable: false here. TextEdit passes it on to the read view it shows first.

The directive's name is open: liveBlockKinds lists the kinds the view reads, each a LiveBlockKind — { name, rowKind, label, empty? }: the name after ::: ([a-z][a-z0-9_]*), the mention kind of its rows, the snapshot card's heading and its line with no rows. The default is the live task list alone (DEFAULT_LIVE_BLOCK_KINDS), and a block of any other name stays text. A kind's rows may drop the box: - [@Row](mention://row/<id>) is a row too, drawn as its chip alone. renderLiveBlock(kind, props) draws every kind, tasks included; without it renderTaskQuery draws tasks alone, and any block it does not draw shows its stored snapshot under its kind's heading. The grammar comes from @/lib/text-edit-grammar: readLiveBlock(source, { names }) (liveBlockNames(kinds)), liveBlockMarkdown and parseLiveBlockRows(snapshot, rowKind).

Next steps

No matching tasks

Document type scale

typeScale="document" is a page read as a document (TYP-20): 14/24 text — 16/26 on a coarse pointer or a narrow window, where editing is 16px too — 12px between paragraphs, # 24 · ## 20 · ### 16 with more room above a heading than below, inline code in the body's ink, and print rules: code wraps, tables print in full, highlights and controls drop out, external links print their address. TextEdit takes the same typeScale, so a page reads and edits at identical metrics. Place it in a DocumentLayout for the page's fluid text column. A table that is wider than the text scrolls in its own box, fading at the edge with more beyond it; while it scrolls the box is a named, focusable region.

Pricing

Pricing for the annual plan is set per seat and billed once a year. Teams that grow during the term add seats at the same rate, prorated to the renewal date.

Seats

A team that shrinks keeps its seats until the term ends. Discounts apply from twenty seats, and every plan includes the same support, storage and security review.

Renewal

  • Annual plans renew on the same date each year
  • Monthly plans can move to annual at any time
PlanSeatsPrice
Starter1–19$12 a seat
Team20–99$10 a seat

Read mode

A reader needs no editor. annotations draw comment highlights over the words each one quotes — resolved from the quote and its context, the way TextEdit re-anchors, and never drawn when the words are gone or appear twice with nothing to tell them apart — with activeAnnotationId, onAnnotationClick, count pills (annotationCounts) and onAnnotationsLayout (each highlight's top, for a comment margin). onOutlineChange reports the headings for a contents list. headingAnchors gives headings their server slug and block id (data-anchor-id), and headingLinkHref adds a # copy-link button in the gutter before each heading's text (after it on a coarse pointer), shown while the heading is hovered, focused or tapped — a press copies the absolute link and says so in a toast, and the text never moves. sectionMarks draws the same section markers and bars as TextEdit beside the text (a dot on the heading, a bar along its section; inside a phone's 16px gutter), and onSectionMark hears a marker's click. toggleState keeps each reader's own open toggles. findQuery highlights every match with the CSS Custom Highlight API — the DOM is never changed — opens any toggle around the current one, and shows a match bar with previous, next and onFindClose.

Pricing review

Check the annual price3 comments before renewal, and confirm the seat count1 comment with finance.

Annual plan

The annual price is set per seat. A team that shrinks keeps its seats until the term ends.

Earlier prices

The 2025 price was $14 a seat.

Limits

Seats are capped at 500 per workspace.

<MarkdownView
  typeScale="document"
  annotations={threads.map((t) => ({
    id: t.id,
    anchor: t.anchor,
    count: t.count,
  }))}
  activeAnnotationId={openThread}
  onAnnotationClick={openThreadById}
  onOutlineChange={setOutline}
  headingAnchors={page.anchors}
  headingLinkHref={(a) => `#${a.slug}~${a.id}`}
  sectionMarks={[
    { key: staleBlockId, tone: "warning", label: "May be out of date" },
  ]}
  onSectionMark={openReview}
  toggleState={toggles}
  findQuery={query}
  onFindClose={() => setQuery("")}
>
  {page.markdown}
</MarkdownView>

Comment on a selection

onCreateAnnotation lets a reader comment without an editor: select words in the view and a small Comment button floats over them — the same button TextEdit's read-only selection bubble shows (commentLabel renames it). Pressing it calls onCreateAnnotation(anchor) with { quote, prefix, suffix, start, end }, computed over the same text annotations resolve against, so handing the anchor straight back as an annotation highlights exactly the selected words. A mention or an image counts whole; a selection of only whitespace, mentions, images or code shows nothing, and so does one that leaves the view. The button sits in a <body> portal above the selection — below it on a coarse pointer, where the platform's own callout sits above — flips and slides to stay in view, and hides while the selection is scrolled out. floatingSafeArea keeps it clear of a sticky app header or a bottom bar ({ top: 56 }); a selection under one counts as scrolled out. Tab moves to it from a selection made with the keyboard and

Esc closes it until the selection changes. The view itself stays server-safe: the button is part of its client effects.

Renewal

Check the annual price before renewal, and confirm the seat count with finance. A team that shrinks keeps its seats until the term ends.

<MarkdownView
  typeScale="document"
  annotations={threads}
  onCreateAnnotation={(anchor) => startThread(anchor)}
  floatingSafeArea={{ top: 56 }}
>
  {page.markdown}
</MarkdownView>

Citations

A summary cites its sources with [[n]] markers (n = 1–999). With citation, each marker whose n the function answers for renders as a small superscript n: a button named by label that opens content (a quote, who said it, when) on hover, keyboard focus or a long press, and calls onSelect on a click or tap — play the recording from there. Without onSelect, a tap opens content. A marker the function returns null for, and every marker without the prop, stays literal text, and so does one inside a code span, fenced code or link text. The marker is a client component, so pass citation from one.

Decisions

  • Ship the pricing page on Friday
  • Keep the free tier, capped at three seats
  • Revisit the enterprise quote after legal's review [[9]]
<MarkdownView
  citation={(n) =>
    sources[n] && {
      label: `Source ${n}`,
      content: sources[n].quote,
      onSelect: () => player.seek(sources[n].start),
    }
  }
>
  {summary}
</MarkdownView>

Block views

blockViews maps a fence's language to a renderer: with blockViews={{ mermaid: mermaidBlockView }} (from Mermaid Block), a ```mermaid fence is a Diagram block — a header with the view's label, Preview / Code (buttons with aria-pressed; ⌘/Ctrl+Enter toggles and Escape goes back to the preview from the block's controls), Expand (a full-screen dialog with pan and pinch) and a ⋯ menu with Copy source and the view's own actions (Copy SVG, Download SVG). Other fences stay code blocks. The preview is client-only: on the server, and before hydration, the fence shows its source in the same frame with the preview's height reserved, so nothing jumps when it draws. The mode is the reader's own and never saved. The entries hold functions, so pass blockViews from a client component, or an entry a client module exports (mermaidBlockView is one). TextEdit takes the same prop for its read view and its editor.

Every page goes through review before it is published.

Diagram
flowchart LR
  accTitle: Publishing a page
  accDescr: A draft goes to review, then is published or sent back for changes.
  draft[Draft] --> review{Review}
  review -->|Approved| published([Published])
  review -->|Changes| draft
  class draft step
  class review decision
  class published outcome

The reviewer is the space's editor.

import { MarkdownView } from "@/components/ui/markdown-view";
import { mermaidBlockView } from "@/components/ui/mermaid-block";

<MarkdownView blockViews={{ mermaid: mermaidBlockView }}>
  {page.body}
</MarkdownView>;

A view of your own is { label, render(source, { mode, theme }) }, plus defaultView, split, starter, hint and actions where it needs them.

API Reference

PropTypeDefaultDescription
activeAnnotationIdstring | nullnullThe annotation shown as active: its highlight fills.
allowedImageOriginsreadonly string[][]Exact HTTP(S) origins allowed to load Markdown images. Relative/same-site paths are always allowed. Absolute and protocol-relative remote images are blocked by default to prevent untrusted Markdown from creating tracking requests. Allowed remote images use a no-referrer policy. ["*"] allows every HTTP(S) origin — for content whose author is trusted to embed images (it is what TextEdit's read view passes, matching the editor).
annotationCountLabel((count: number) => string)(count) => count === 1 ? "1 comment" : `${count} comments`The count pill's accessible name (the visible number is aria-hidden).
annotationCounts"always" | "auto" | "never""never"A count pill after each highlight: auto only on a coarse pointer or below the lg width (where a comment margin has no room), always everywhere, never none.
annotationLabel((quote: string, count: number) => string)(quote, count) => count > 1 ? `${count} comments on “${quote}”` : `Comment on “${quote}”`A highlight's accessible name, from its quoted words and count.
annotationsreadonly MarkdownAnnotation[]—Comment highlights over the words each one quotes (resolved through its quote and context, the way TextEdit re-anchors). One that matches no text, or two places equally well, draws nothing. Rendered as spans at render time, never by touching the DOM after.
blockViewsReadonly<Record<string, BlockView>>—Fenced languages drawn by a block view instead of as code: { mermaid: mermaidBlockView } draws ``` mermaid fences as diagrams. The block shows a header with the view's label, Preview / Code, Expand and a ⋯ menu (Copy source and the view's own actions). The preview is client-only: the server, and the first render before hydration, show the source in the same frame with the preview's height reserved. The entries hold functions, so pass this from a client component (or pass an entry exported by a client module, such as mermaidBlockView`). @default undefined
childrenstring—The markdown string to render. Provided as children (preferred) or via the content prop — when both are present, children wins.
citation((n: number) => MarkdownCitation | null | undefined)—When set, a [[n]] marker (n = 1–999) in text renders as a small superscript citation n built from what this returns; null/undefined for an n leaves the literal text. Without the prop markers stay literal text. Never inside code spans, fenced code or link text. Markdown only. The marker is a client component, so pass this from a client component.
commentLabelstring"Comment"The selection button's label (onCreateAnnotation).
contentstring—The markdown string to render. Alternative to children; useful when the source comes from a data field rather than JSX text.
embedsPartial<Readonly<Record<EmbedProviderId, EmbedProvider>>>—The video providers whose links alone on a line render as a player facade (MarkdownEmbed): EMBED_PROVIDERS from @/lib/embed-providers, or a subset. Opt-in — without it such a line is a plain link, so nothing is framed until the page allows EMBED_FRAME_ORIGINS in frame-src.
fileContentType((href: string) => string | null | undefined)—A file chip's content type, by its href, when the host knows it (a comment's own files): the chip's icon then follows the type, not only the name's extension.
fileLinkPrefixstring"/api/files/"A link whose href starts with this renders as a file chip — an icon for the file's type and its name. "" turns file chips off.
findLabels{ position?: (current: number, total: number) => string; none?: string; previous?: string; next?: string; close?: string; label?: string; }—The match bar's strings.
findQuerystring—Find in the rendered text: every match is highlighted (the CSS Custom Highlight API, so the DOM is never changed), the first scrolls into view (any toggle around it opens), and a match bar shows "1 of 12" with previous and next. Where the API is missing, it only scrolls.
floatingSafeArea{ top?: number; bottom?: number; }—Px the selection button keeps clear at the viewport's top and bottom, beyond its 8px edge gap — the height of a sticky app header or a bottom bar — so it never sits under one, and counts a selection under one as scrolled out.
format"html" | "markdown"'markdown'What the source is: Markdown (CommonMark + GFM), or HTML — TextEdit's format="html" output. HTML is rebuilt through a tag and attribute allowlist (the elements rich text uses), never injected: scripts, styles, event handlers and unsafe URLs are dropped.
headingAnchorsreadonly MarkdownHeadingAnchor[]—The headings' slugs and block ids, in document order (a server-derived list). A heading whose slug matches its entry takes the slug as its id and the block id as data-anchor-id, so a #slug~blockId link keeps working after the heading moves. Turns headingIds on.
headingIdsbooleanfalseGive every heading an id — its words, hyphenated, -1, -2… for a repeat (headingIds) — the same ids TextEdit's onOutlineChange reports, so an outline links into either.
headingLinkHref((anchor: MarkdownHeadingAnchor) => string | null)—Where a heading's link points (a path or an absolute URL). Set, each heading with an id gets a # copy-link button in the gutter before its text (after it on a coarse pointer), shown while the heading is hovered, focused or tapped; a press copies the absolute link and says so in a toast. Return null for none.
headingOffsetnumber0Move every heading this many levels down, capped at h6: 1 renders # as an h2, 2 as an h3. Use it when the markdown sits under the page's own headings (a card titled with an h2), so the document outline never skips back up.
liveBlockKindsreadonly LiveBlockKind[]DEFAULT_LIVE_BLOCK_KINDS — the live task list (`tasks`) aloneThe live block kinds this view reads (:::<name>{…} … :::), each with the heading and row kind its stored snapshot shows with (MarkdownLiveBlock); a block of any other name stays text and a list. Markdown only.
mentionHref((kind: MentionKind, id: string) => string | null)—Where a mention chip links, by kind and id ([@Q3 plan](mention://page/p1)). Return null for no link. People are never links, and neither is a restricted: id.
mentionImage((id: string) => string | null | undefined)—A person mention's avatar URL, by id: the chip shows it (16px) in place of the person icon.
mentionStatus((kind: MentionKind, id: string) => MarkdownMentionStatus | null | undefined)—A target's state, by kind and id: { done: true } gives a finished task's chip its check (a task chip shows the task glyph otherwise); { struck: true, status: "Canceled" } strikes the chip's label through and adds a small status tag (a canceled or duplicate task). null leaves the chip to the InlineChipProvider's state; {} is a plain chip. Any state given here replaces the provider's whole answer.
onAnnotationClick((id: string) => void)—Called with an annotation's id to open its thread: a click on its highlight or its count pill, or Enter / Space on its highlight, which is then a tab stop (a button named by annotationLabel). Without it, highlights are neither tab stops nor buttons.
onAnnotationsLayout((items: MarkdownAnnotationLayout[]) => void)—Each highlight's top edge (px from this view's top; null when it drew nothing), after render and whenever the view resizes — for laying comments in a margin beside the text.
onCreateAnnotation((anchor: MarkdownAnnotation["anchor"]) => void)—Comment on a selection. Set, selecting words inside the view shows a small floating "Comment" button — TextEdit's read-only selection bubble — and pressing it calls this with the selection's anchor, computed over the same text annotations resolve against: pass it straight back as an annotation's anchor and exactly the selected words highlight. A mention or an image counts whole; a selection of only whitespace, mentions, images or code shows nothing. The button floats in a <body> portal above the selection (below it on a coarse pointer, where the platform's own callout sits above), flips and shifts to stay in view, hides while the selection is scrolled out, closes on Escape, and Tab moves to it from a selection made with the keyboard.
onFindClose(() => void)—The match bar's close button (and Escape inside the bar).
onOutlineChange((headings: MarkdownOutlineItem[]) => void)—The headings #–#### with their ids, after render and whenever they change — an outline rail, computed from the Markdown with no editor. Turns headingIds on.
onSectionMark((key: string) => void)—A section mark's marker was clicked: its key. Without it the marker is a labelled dot.
onTaskOpen((taskId: string) => void)—A linked checklist line — a task item whose first inline is a task mention — draws as a TaskInline: one round box, the title (a click calls this: open the task's peek), the assignee's avatar and the due date (mentionStatus's assignee, due, overdue, else the InlineChipProvider's state) and ↗ to its page (mentionHref) on hover — as TextEdit does with the same prop. Without it the line is a checkbox and a task chip.
renderLiveBlockLiveBlockRender—Draws a live block of any kind (:::<kind>{…} … :::, a name in liveBlockKinds) in place of its stored snapshot, called with the block's kind and editable: false. Given, it is asked for every kind, tasks included, and renderTaskQuery is not used. Returning null/undefined leaves the snapshot (MarkdownLiveBlock).
renderTaskQuery((props: TaskQueryRenderProps) => React.ReactNode)—Draws a live task list (:::tasks{…} … :::) — a host component that fetches the list for this reader — in place of its stored snapshot. Called with editable: false. Without it (and where it returns null/undefined) the snapshot shows as a quiet list (MarkdownLiveBlock): a public view never gets live data. The function runs during render; pass it from a client component, or build the view on the server.
sectionMarksreadonly MarkdownSectionMark[]—Marked sections, by their heading's block id (headingAnchors sets it): each draws a small marker in the gutter beside its heading (named label) and a tone bar along the section, beside the text — never moving it. The same marks TextEdit draws. A key matching no heading draws nothing.
tableLabelstring"Table, scrolls sideways"A table box's accessible name while its table scrolls sideways.
toggleStateMarkdownToggleState—Each reader's own open or closed toggles: isOpen decides, onToggle hears every change. Without it, a toggle opens as the document says and is forgotten on reload.
typeScale"compact" | "document""compact"The type scale. compact is the app's own (14px text, a # stops at text-lg), right for a comment, a message or a card. document is a page read as a document (TYP-20): 14/24 text (16/26 on a coarse pointer or a narrow window, where editing is 16px too), 12px between paragraphs, # 24 · ## 20 · ### 16 with more room above a heading than below, inline code in the body's ink, and print rules (documentPrintClassName). TextEdit's typeScale is the same overlay, so a page reads and edits at identical metrics.

Data attributes and CSS variables on MarkdownView

AttributeValues
data-finding""
data-slot"markdown-view"
data-type-scalemirrors a prop or state value
data-view-idmirrors a prop or state value
PropTypeDefaultDescription
label*string—The block's name, shown in its header: "Diagram".
render*(source: string, ctx: BlockViewRenderContext) => React.ReactNode—The preview for source. Called on the client only; the server renders the source.
actionsreadonly BlockViewAction[]—Extra ⋯ menu items, after Copy source.
defaultView"preview" | "source""preview"What a block opens in. A new empty block (or one still holding starter) always opens in Code while it is being edited.
hint((source: string) => string | null | undefined)—A one-line hint shown under the source in Code mode; null hides it.
splitbooleanfalseIn the editor, Code shows source and live preview side by side when the block is at least 45rem (720px) wide.
starterstring—What a new block of this kind starts with (the slash menu's and Turn into's insert).
PropTypeDefaultDescription
label*string—The marker's accessible name, e.g. "Source 3".
contentReact.ReactNode—What hovering, focusing or long-pressing the marker shows (a quote, who said it, when).
onSelect(() => void)—Clicking or tapping the marker (e.g. play the recording from there). Absent: a tap only opens content.
PropTypeDefaultDescription
id*string—The target's id. An id starting with restricted: renders a muted chip that is never a link.
kind*InlineChipKind—What the chip points at; picks the icon (and, for a person, the blue ground).
label*string—The name shown.
contentTypestring | null—A file chip's content type: the icon follows it, not only the name's extension.
donebooleanfalseA finished target (a done task): the chip leads with a check instead of its kind's glyph.
hrefstring | nullthe provider's href(kind, targetId)Where the chip links. Overrides the provider's href. null for no link.
imagestring | null—A user chip's photo, when there is no person.
onOpen((target: InlineChipTarget, event: React.MouseEvent | React.KeyboardEvent) => void | boolean)the provider's onOpenOpens the chip on a plain click or Enter; ⌘/Ctrl-click still opens href in a new tab. Overrides the provider's onOpen.
openOn"click" | "modifier""click"click — a plain click opens. modifier — only ⌘/Ctrl-click opens (in a new tab), so a click inside an editor places the caret instead.
personInlineChipPerson | nullthe provider's person(targetId)A user chip's person: their photo shows in the chip, and the hover card shows the avatar, name and email. Overrides the provider's person.
previewReact.ReactNodethe provider's preview(kind, targetId)The hover card's body for a non-person chip. null turns the card off.
restrictedbooleantargetId starts with "restricted:"A muted chip that never links or opens: a target the reader may not see.
statusstring—A small status tag after the label ("Canceled", "Duplicate") — the same muted outline badge as "Inactive". "" or undefined shows none.
struckbooleanfalseThe label struck through: a target that is closed without being done (a canceled or duplicate task). The chip carries data-struck; its tint and link are unchanged.

MarkdownLiveBlock takes a div's props plus:

PropTypeDefaultDescription
snapshot*string—The block's rows as stored: - [x] [@Title](mention://task/<id>) — <status> or - [@Row](mention://row/<id>) lines.
kindLiveBlockKindDEFAULT_LIVE_BLOCK_KINDS[0] — the live task listThe block's kind: its heading (label), the mention kind of its rows (rowKind) and its line with no rows (empty).
mentionHref((kind: MentionKind, id: string) => string | null)—A row chip's link, as MarkdownView's prop of the same name; without it the nearest InlineChipProvider links it.

Data attributes and CSS variables on MarkdownLiveBlock

AttributeValues
data-checkedmirrors a prop or state value
data-kindmirrors a prop or state value
data-slot"markdown-live-block" | "markdown-live-block-row"

renderTaskQuery is called with:

PropTypeDefaultDescription
blockId*string—The block's id — the directive's id, which the host keys the block by.
editable*boolean—Whether the reader can change the list's filter: an editable editor.
query*Record<string, string>—Its filter: the directive's other attributes (v, space, assignee, status, …), as written.
snapshot*string—The rows last stored: - [ ] [@Title](mention://task/<id>) — <status> lines, or the kind's own (parseLiveBlockRows).
setQuery((query: Record<string, string>) => void)—Replace the list's filter (an editable editor only; absent in read views): a local edit of the node's query — one undo step, carried to collaborators like any other edit. Keys other than [a-z][a-z0-9-]* are dropped and line breaks in values become spaces, so the filter reads back as written. A call after the editor turns read-only does nothing.
PropTypeDefaultDescription
anchor*{ quote: string; prefix?: string; suffix?: string; start?: number; end?: number; }—Where it sits: the quoted words and up to 80 characters of context on each side; start / end are offsets into the text when the host has them (they are checked against the quote).
id*string—The comment (thread) id — the highlight's data-annotation.
countnumber1The thread's comment count — its pill's number and its accessible name.
PropTypeDefaultDescription
slug*string—The heading's slug: its words, hyphenated, -1, -2… for a repeat (headingIds).
idstring—The heading's block id (blockId), stable across edits. Absent when the host has none.
PropTypeDefaultDescription
isOpen*(id: string, fallback: boolean) => boolean—Whether the toggle with this id is open for this reader; fallback is the document's own state.
onToggle*(id: string, open: boolean) => void—The reader opened or closed a toggle.
idsreadonly string[]—The toggles' ids (their block ids), in document order. Without it, toggle-0, toggle-1…

Accessibility

  • Markdown maps to semantic HTML — # → <h1>, - → <ul><li>, > → <blockquote>, ``` → <pre><code> — so the document structure is exposed to assistive technology and heading navigation works.
  • XSS-safe by construction: every element is rebuilt as a React element — never dangerouslySetInnerHTML — and raw HTML in markdown renders as text, so <script>, <img onerror>, and inline event handlers in untrusted input render as plain text rather than executing. format="html" keeps only allowlisted tags and attributes.
  • A callout is a role="note" named by its tone ("Note", "Tip", "Warning"); its icon is decorative. A toggle is a native <details>/<summary>: Enter or Space on the summary opens and closes it.
  • A mention chip that links is an ordinary link; one that does not (a person, a restricted target) is plain text with an icon.
  • Links open in a new tab with rel="noreferrer noopener" (no reverse tabnabbing); unsafe URL protocols (e.g. javascript:) are dropped.
  • Remote images are blocked unless their exact HTTP(S) origin appears in allowedImageOrigins; allowed remote images use referrerPolicy="no-referrer".
  • Body text inherits the page text color (text-foreground); links use text-info-text with an underline, keeping a visible, color-independent affordance.
KeyAction
TabMove focus through rendered links in document order.
EnterFollow the focused link.

A citation marker is a button named by its label ("Source 3"); keyboard focus opens its source without moving focus, and Enter selects it.

With onAnnotationClick, a comment highlight is one tab stop — a button named for its whole quote and count ("3 comments on “annual price”") — that opens on Enter or Space; the rest of a highlight split by formatting is hidden from assistive technology, which heard it in that name. A count pill's number is aria-hidden; its name is the sentence ("3 comments"). The find bar is a search landmark ("Find in page") docked at the window's bottom end: its Previous and Next buttons step through the matches, Esc closes it, and "2 of 12" is announced politely. Overlapping highlights never nest: each thread has exactly one tab stop (where another thread covers exactly its words, its count pill is that stop). A heading's copy-link button is named "Copy link to “Pricing”". A table that scrolls is a focusable region named "Table, scrolls sideways", so the arrow keys scroll it.

With onCreateAnnotation, the selection's Comment button is a toolbar named "Selection actions" holding one button named by commentLabel. Tab from a selection inside the view (focus on the page or in the view) moves focus to it, Shift+Tab goes back,

Enter comments and Esc closes it. A press on it keeps the selection.

ContractStates tested
Behaviourdefault, active, checked, disabled, empty, open, annotated, selection-comment
Accessibilitysemantic-html, keyboard-operable
Visualdefault, hover, disabled, empty

Do / Don't

Do
Use MarkdownView for trusted-or-untrusted markdown — it escapes raw HTML, so user-authored comments and descriptions render safely without extra sanitization.
Don't
Inject embedded HTML with dangerouslySetInnerHTML around it — it re-enables the XSS vectors this component exists to avoid. Use format="html" for rich-text HTML; it rebuilds only allowlisted tags.

On this page