V2 componentsUpdated Oct 3, 2026
Markdown
The one markdown surface. Read-only it renders GitHub-flavored markdown; with editable it swaps in a Tiptap editor that looks the same, because one stylesheet draws both. Headings, lists, quotes, code, tables and links take their type, rhythm and tokens from the system.
Source: packages/ui/src/components/markdown
import { Markdown, slackFeatures, githubFeatures, inlineOnlyFeatures } from '@highlight/ui'Change the props; the preview and the snippet update together.
Standup, Thursday
Highlight heard three commitments and attributed two to you.
- Capture
- On device
Port the foundation
Move the chat surfaces over
Nobody agreed to March.
const gap = 16const source = `## Standup, Thursday - [x] Port the foundation` <Markdown>{source}</Markdown>Pass the source as children. The elements style themselves; nothing needs a class.
Standup, Thursday
Highlight heard three commitments and attributed two to you. The other has no owner, so it is
still on the list. See the plan or run hl status.
- Capture
- On device
- Ship the
Markdowncomponent
- Open the capture
- Review the summary
Nobody agreed to March. Two people left believing it was agreed.
Below the rule.
import { Markdown } from '@highlight/ui' const SOURCE = `## Standup, Thursday Highlight heard **three commitments** and attributed two to you. The other has no owner, so it isstill on the list. See [the plan](https://example.com/plan) or run \`hl status\`. - Capture - On device- Ship the \`Markdown\` component 1. Open the capture2. Review the summary > Nobody agreed to March. Two people left believing it was agreed. --- Below the rule.` export default function MarkdownBasic() { return ( <div className="w-full max-w-xl"> <Markdown>{SOURCE}</Markdown> </div> )}document is for a page: ## is the section heading and everything below steps down from it. compact is for a narrow column such as a side panel or chat reply: # is 22px, ## is 18px. specimen runs the whole scale, # at display size.
Heading 1
Heading 2
Heading 3
Heading 4
Heading 5
Heading 1
Heading 2
Heading 3
Heading 4
Heading 5
Heading 1
Heading 2
Heading 3
Heading 4
Heading 5
import { Markdown } from '@highlight/ui' const SOURCE = `# Heading 1## Heading 2### Heading 3#### Heading 4##### Heading 5` export default function MarkdownHeadings() { return ( <div className="grid w-full max-w-5xl gap-8 sm:grid-cols-3"> {(['document', 'compact', 'specimen'] as const).map((ladder) => ( <div key={ladder} className="flex min-w-0 flex-col gap-3"> <span className="text-foreground-secondary text-xs">ladder="{ladder}"</span> <Markdown ladder={ladder}>{SOURCE}</Markdown> </div> ))} </div> )}md is body copy. sm is the dense reading size, with tighter rhythm, for transcripts and side panels. inherit sets no type or colour of its own, so the text takes the font and colour of whatever it sits in, with md rhythm.
Standup, Thursday
Highlight heard three commitments and attributed two to you.
- Capture
- On device
Port the foundation
Move the chat surfaces over
Nobody agreed to March.
const gap = 16Standup, Thursday
Highlight heard three commitments and attributed two to you.
- Capture
- On device
Port the foundation
Move the chat surfaces over
Nobody agreed to March.
const gap = 16import { Markdown } from '@highlight/ui' const SOURCE = `## Standup, Thursday Highlight heard **three commitments** and attributed two to you. - Capture - On device- [x] Port the foundation- [ ] Move the chat surfaces over > Nobody agreed to March. \`\`\`tsconst gap = 16\`\`\`` export default function MarkdownSizes() { return ( <div className="grid w-full max-w-3xl gap-8 sm:grid-cols-2"> {(['md', 'sm'] as const).map((size) => ( <div key={size} className="flex min-w-0 flex-col gap-3"> <span className="text-foreground-secondary text-xs">size="{size}"</span> <Markdown size={size}>{SOURCE}</Markdown> </div> ))} </div> )}A fenced block is a figure: the language and a copy button in its head, then highlighted code. lineNumbers adds a sticky gutter.
// Radius is three eighths of the height, so every size looks equally round.
export function radius(height: number) {
return (height * 3) / 8
}.hl-btn[data-size="md"] { --hl-btn-h: 28px; }// Radius is three eighths of the height, so every size looks equally round.export function radius(height: number) { return (height * 3) / 8}.hl-btn[data-size="md"] { --hl-btn-h: 28px; }import { Markdown } from '@highlight/ui' const SOURCE = `\`\`\`ts// Radius is three eighths of the height, so every size looks equally round.export function radius(height: number) { return (height * 3) / 8}\`\`\` \`\`\`css.hl-btn[data-size="md"] { --hl-btn-h: 28px; }\`\`\`` export default function MarkdownCodeBlock() { return ( <div className="flex w-full max-w-xl flex-col gap-6"> <Markdown>{SOURCE}</Markdown> <Markdown lineNumbers>{SOURCE}</Markdown> </div> )}GFM task items share one marker column with bullets and numbers, and a done item dims its own text.
Typography
Icons
Lucide only
Shadow
Buttons
import { Markdown } from '@highlight/ui' const SOURCE = `- [x] Typography- [x] Icons - [x] Lucide only- [ ] Shadow- [ ] Buttons` export default function MarkdownTaskList() { return ( <div className="w-full max-w-xl"> <Markdown>{SOURCE}</Markdown> </div> )}A table scrolls sideways inside its own box, so a wide one never widens the page.
| Token | System | Notes |
|---|---|---|
| background | gray-1 | The page |
| border | gray-a-6 | The default hairline |
| link | blue-11 | Underlined, and firmer on hover |
import { Markdown } from '@highlight/ui' const SOURCE = `| Token | System | Notes ||---|---|---|| background | gray-1 | The page || border | gray-a-6 | The default hairline || link | blue-11 | Underlined, and firmer on hover |` export default function MarkdownTable() { return ( <div className="w-full max-w-xl"> <Markdown>{SOURCE}</Markdown> </div> )}Syntax GFM does not have is opt-in: ==mark== and [[kbd]]. components replaces any element; keep the map outside the component so it stays stable.
import { Markdown, type MarkdownProps } from '@highlight/ui' const SOURCE = `The part that matters is ==nobody agreed to March==. Press [[Cmd]] [[Shift]] [[4]] to capture. See [the plan](https://example.com/plan) and [the notes](https://example.com/notes).` // Declared outside the component: a new map each render rebuilds the whole tree.const COMPONENTS: MarkdownProps['components'] = { a: ({ node: _node, ...props }) => <a target="_blank" rel="noreferrer" {...props} />,} const PLUGINS = [Markdown.remarkMark, Markdown.remarkKbd] export default function MarkdownPlugins() { return ( <div className="w-full max-w-xl"> <Markdown remarkPlugins={PLUGINS} components={COMPONENTS}> {SOURCE} </Markdown> </div> )}editable swaps in Tiptap, loaded on demand, so a read-only Markdown never ships it. defaultValue seeds it once; onChange gets the markdown and the editor. Select text for the floating toolbar.
import { useState } from 'react'import { Markdown } from '@highlight/ui' const INITIAL = `## Launch retro - [x] Move the release date- [ ] Split the migration into two steps Owner: **Lin**. Select text for the toolbar.` export default function MarkdownEditable() { const [markdown, setMarkdown] = useState(INITIAL) return ( <div className="grid w-full max-w-3xl gap-8 sm:grid-cols-2"> <div className="flex min-w-0 flex-col gap-3"> <span className="text-foreground-secondary text-xs">editable</span> <Markdown editable defaultValue={INITIAL} onChange={setMarkdown} placeholder="Start writing…" /> </div> <div className="flex min-w-0 flex-col gap-3"> <span className="text-foreground-secondary text-xs">read-only, the same source</span> <Markdown>{markdown}</Markdown> </div> </div> )}The root, then any compound sub-components. Every part accepts className and forwards the rest of its props to the element or Base UI part beneath it.
Markdown- The editor, read-only unless editable. value or children set the content; features pick the syntax it accepts.
Markdown.Prose- A container with the markdown styles and nothing else, for HTML or React children you already have. Takes size, ladder and measure; nothing is parsed.
Markdown.CodeBlock- The default for a fenced block: a figure with the language and a copy button in its head, then lowlight-highlighted code. Takes lineNumbers.
Markdown.Table- The default for a table: scrolls sideways inside its own box.
Markdown.Paragraph- The default for a paragraph: one that is only an image becomes a figure, with the title as its caption.
Markdown.remarkMark- Remark plugin: ==text== becomes <mark>. Pass it in remarkPlugins.
Markdown.remarkKbd- Remark plugin: [[Cmd]] becomes <kbd>. Pass it in remarkPlugins.
Props added on top of the underlying element or Base UI part.
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | — | The markdown source. |
size | 'md' | 'sm' | 'inherit' | 'md' | md is body copy. sm is the dense size: prose-sm, tighter rhythm, headings no larger than md. inherit sets no type or colour of its own: the text takes the font and colour of its container, with md rhythm. |
ladder | 'document' | 'compact' | 'specimen' | 'document' | The heading scale. document starts at ##; compact tops out at 22px for narrow columns; specimen runs h1 to h4 as display to h3. Ignored at sm. |
measure | boolean | false | Caps paragraphs at 72ch. Off, because most surfaces already set a width. |
lineNumbers | boolean | false | A sticky line-number gutter on code blocks. |
components | Components | — | react-markdown component overrides, merged over the defaults. Memoise the map. |
remarkPlugins | PluggableList | — | Appended after remark-gfm. |
rehypePlugins | PluggableList | — | Appended after the built-in table and task-list transforms. |
urlTransform | UrlTransform | — | Replaces react-markdown's URL sanitizer, which blanks unknown protocols. |
renderImage | MarkdownImageRenderer | — | Renders image nodes, for src values that are app pseudo-URLs. Read-only and editable. |
ref | Ref<HTMLDivElement> | — | The root element. |
className | string | — | Goes on the .markdown element. |
editable | boolean | false | Renders a Tiptap editor styled as the read-only output. Loaded on demand, in the browser. |
value | string | — | The source for an editable Markdown, kept in sync except while the editor has focus. children is used if this is omitted. |
defaultValue | string | — | Seeds an editable Markdown once, on mount; later changes are ignored. |
onChange | (markdown: string, editor: Editor) => void | — | Called with the source and the Tiptap editor as the document changes. Editable only. |
placeholder | string | — | Shown while an editable document is empty. |
readOnly | boolean | false | Locks an editable Markdown without unmounting the editor, so its content and history survive. |
autoFocus | boolean | 'start' | 'end' | 'all' | number | — | Focuses an editable Markdown on mount. |
features | MarkdownFeatures | — | Which formatting an editable Markdown allows. Omit for everything, or pass a preset (slackFeatures, githubFeatures, ...) and override fields. |
mentionables | MentionablesHandler | — | Platform-aware @ and # mentions, e.g. createSlackMentionables. |
maxLength | number | — | Hard cap on an editable document, in plain-text characters. |
extensions | Array<AnyExtension> | — | Extra Tiptap extensions, appended after the built-in set. Read once, on mount. |
toolbar | 'bubble' | 'docked' | false | 'bubble' | The formatting bar: floating over a selection, docked above the text, or none. |
editorRef | Ref<Editor> | — | Receives the Tiptap editor once it has loaded, and null when it unmounts. Pass a state setter or a ref object. |
- The stylesheet is markdown-content.css. It styles bare elements under .markdown at zero specificity, so it reaches react-markdown and Tiptap output alike and any utility class still wins.
- Every gap is a margin on the block below it, so adjoining margins never stack. The gap above a heading states its rank; the gap below binds it to its content.
- Tiptap is a separate chunk, loaded when the first editable Markdown renders. A read-only Markdown never imports it.
- remark-math and rehype-katex are not built in; pass them in remarkPlugins and rehypePlugins where the source is math.