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
- Since
0.1.0- Accessibility pattern
- semantic HTML document
Last updated
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-viewInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/markdown-viewRun 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"]}>
{""}
</MarkdownView>An image's width set by resizing it in TextEdit travels in its alt text — 
renders 320px wide (never wider than the text column) with the alt "Diagram"; 
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
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.
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.
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.
Links
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.
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.
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.
<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 anInlineChip: 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 (returnnullfor none); a person is never a link, and an id starting withrestricted: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 inTextEdit. Any kind matching[a-z][a-z0-9_]{1,31}is a mention: a kind the app names in theInlineChipProvider'skinds([@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 arole="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 —
headingIdsgives every heading the idTextEdit'sonOutlineChangereports (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.
<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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
preview | InlineChipLinkPreview | null | — | The preview, when the host already has it: shown as given, nothing is asked. |
Data attributes and CSS variables on MarkdownBookmark
| Attribute | Values |
|---|---|
data-anchor-block | "" |
data-anchor-text | mirrors 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).
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.
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.
<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.
<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.
<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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
activeAnnotationId | string | null | null | The annotation shown as active: its highlight fills. |
allowedImageOrigins | readonly 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. |
annotations | readonly 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. |
blockViews | Readonly<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 |
children | string | — | 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. |
commentLabel | string | "Comment" | The selection button's label (onCreateAnnotation). |
content | string | — | The markdown string to render. Alternative to children; useful when the
source comes from a data field rather than JSX text. |
embeds | Partial<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. |
fileLinkPrefix | string | "/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. |
findQuery | string | — | 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. |
headingAnchors | readonly 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. |
headingIds | boolean | false | Give 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. |
headingOffset | number | 0 | Move 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. |
liveBlockKinds | readonly LiveBlockKind[] | DEFAULT_LIVE_BLOCK_KINDS — the live task list (`tasks`) alone | The 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. |
renderLiveBlock | LiveBlockRender | — | 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. |
sectionMarks | readonly 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. |
tableLabel | string | "Table, scrolls sideways" | A table box's accessible name while its table scrolls sideways. |
toggleState | MarkdownToggleState | — | 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
| Attribute | Values |
|---|---|
data-finding | "" |
data-slot | "markdown-view" |
data-type-scale | mirrors a prop or state value |
data-view-id | mirrors a prop or state value |
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
actions | readonly 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. |
split | boolean | false | In the editor, Code shows source and live preview side by side when the block is at least 45rem (720px) wide. |
starter | string | — | What a new block of this kind starts with (the slash menu's and Turn into's insert). |
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | The marker's accessible name, e.g. "Source 3". |
content | React.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. |
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
contentType | string | null | — | A file chip's content type: the icon follows it, not only the name's extension. |
done | boolean | false | A finished target (a done task): the chip leads with a check instead of its kind's glyph. |
href | string | null | the provider's href(kind, targetId) | Where the chip links. Overrides the provider's href. null for no link. |
image | string | null | — | A user chip's photo, when there is no person. |
onOpen | ((target: InlineChipTarget, event: React.MouseEvent | React.KeyboardEvent) => void | boolean) | the provider's onOpen | Opens 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. |
person | InlineChipPerson | null | the 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. |
preview | React.ReactNode | the provider's preview(kind, targetId) | The hover card's body for a non-person chip. null turns the card off. |
restricted | boolean | targetId starts with "restricted:" | A muted chip that never links or opens: a target the reader may not see. |
status | string | — | A small status tag after the label ("Canceled", "Duplicate") — the same muted outline badge
as "Inactive". "" or undefined shows none. |
struck | boolean | false | The 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:
| Prop | Type | Default | Description |
|---|---|---|---|
snapshot* | string | — | The block's rows as stored: - [x] [@Title](mention://task/<id>) — <status> or - [@Row](mention://row/<id>) lines. |
kind | LiveBlockKind | DEFAULT_LIVE_BLOCK_KINDS[0] — the live task list | The 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
| Attribute | Values |
|---|---|
data-checked | mirrors a prop or state value |
data-kind | mirrors a prop or state value |
data-slot | "markdown-live-block" | "markdown-live-block-row" |
renderTaskQuery is called with:
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
count | number | 1 | The thread's comment count — its pill's number and its accessible name. |
| Prop | Type | Default | Description |
|---|---|---|---|
slug* | string | — | The heading's slug: its words, hyphenated, -1, -2… for a repeat (headingIds). |
id | string | — | The heading's block id (blockId), stable across edits. Absent when the host has none. |
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
ids | readonly 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 usereferrerPolicy="no-referrer". - Body text inherits the page text color (
text-foreground); links usetext-info-textwith an underline, keeping a visible, color-independent affordance.
| Key | Action |
|---|---|
| Tab | Move focus through rendered links in document order. |
| Enter | Follow 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.
| Contract | States tested |
|---|---|
| Behaviour | default, active, checked, disabled, empty, open, annotated, selection-comment |
| Accessibility | semantic-html, keyboard-operable |
| Visual | default, hover, disabled, empty |