# Markdown
A Markdown string, rendered as this system’s components.
- Group: Display
- Import: `import { Markdown } from '@misoto22/design'`
- Page: https://ui.misoto22.com/components/markdown/
- Related: article, code-block, heading, text
## When to reach for it
Content nobody on this side wrote — a comment, a README, a model’s answer. Trusted HTML from your own pipeline is an Article.
## Anatomy
- **Fragment** (required) — What it renders. There is no wrapper element, because Article’s rhythm is a direct-child combinator — anything between the two, display: contents included, would cost every paragraph its spacing.
- **Parser** (required) — parseMarkdown by default: ATX headings, paragraphs, fenced code, blockquotes, nested lists, thematic breaks, and inline emphasis, strong, code, links, images and escapes. Raw HTML is dropped rather than rendered, so this path has no dangerouslySetInnerHTML in it at all.
- **Nodes** (required) — The system’s own components: Heading, Text, Code, CodeBlock and Separator. That is what makes it styled on its own rather than only inside a reading column. One of them is not server-only — a fenced block renders CodeBlock, which is ‘use client’ with useState, useEffect and two icons, so content containing code brings a client component with it.
- **Heading ids** (required) — Slugged from each heading’s own text, in any script, deduplicated within the document with -2, -3. Exported as slugify, so a table of contents can arrive at the same ids without reading them back off the DOM.
## Best practices
### Do
- Set headingLevelStart to the level BELOW the heading the content sits under. Markdown is written as a document, so its # is an
; dropped into a page that already has one, that is two first-level headings and an outline nobody can navigate.
- Put it inside an Article — for a comment or an answer, for a document. Markdown makes the nodes, Article is the column they sit in and the only thing that spaces them, which is also why Markdown renders no element of its own.
- Pass idPrefix when two documents share a page. Both would otherwise claim #installation, and a fragment link lands on whichever the browser found first.
- Bring your own parser through parse for tables, footnotes or task lists. The package ships no parser dependency on purpose: markdown-it measures 110.7 kB minified against the 38.9 kB this package had left under its bundle budget.
### Don’t
- Do not hand it HTML. It parses Markdown; a string of tags renders as the text of those tags, which is the safe answer and not the one you wanted — trusted HTML belongs in Article.
- Do not expect the rhythm without a surface, and do not try to buy it with a gap. Every node renders m-0 into a bare fragment, and prose spacing is not uniform — article.css gives a heading 2.25em above and 0.75em below, which is what sits it with the paragraph it introduces. A uniform gap on a container cannot reproduce that; can.
- Do not assume the full GFM surface. Tables, footnotes, task lists, setext headings, reference links and hard breaks are outside the built-in parser, and asking for one silently gets you a paragraph.
## Accessibility
- headingLevelStart shifts the whole document at once, so nested content keeps a valid outline instead of restarting at h1.
- Every heading gets a stable, script-preserving id, deduplicated in document order, so a table of contents can link into it.
- A link whose href carries a scheme that is not http, https, mailto or tel renders as plain text — javascript: never becomes a control.
- A link that leaves for another site carries rel="noreferrer nofollow", so an untrusted author cannot spend the page’s ranking or read its URL out of the Referer. It is not configurable; markExternalLinks adds the visible outbound arrow and is off by default.
- A malformed or empty string renders nothing rather than throwing, which is the normal case for content a reader wrote.
## Markdown
A Markdown string, rendered as this system's components. The gap it fills is content nobody on this side wrote: a comment, a README, a model's answer, a description out of a database. `Article` cannot take those — it renders HTML through `dangerouslySetInnerHTML` and is documented as trusted-input-only — and until now the alternative was a `
` or a sanitiser bolted onto a styling primitive. IT IS NOT AN `Article`, and the two do not merge. `Markdown` turns a string into nodes; `Article` is the reading column those nodes can sit in. It renders a FRAGMENT rather than a wrapper, which is what makes that nesting work — `Article`'s rhythm is a direct-child combinator, so any element between the two, `display: contents` included, would cost every paragraph its spacing. ── It brings type and colour, and no vertical rhythm ── The nodes carry the system's faces and inks on their own, so one of these looks right in isolation. It is not SPACED in isolation: every node renders `m-0`, and a fragment has no box of its own to put a gap in. Nor does a uniform `gap` on a container reproduce it, because prose spacing is not uniform — `article.css` gives a heading 2.25em above and 0.75em below, which is what sits a heading with the paragraph it introduces rather than midway between two. For anything longer than a sentence — a comment, an answer, a README — put it in `` and get the real rhythm for the cost of one element. ── A fenced code block brings a client component with it ── The renderer is plain functions and has no state, but a fenced block renders `CodeBlock`, which is `'use client'` and arrives with `useState`, `useEffect` and two icons. That is correct — the copy button is the reason the block exists — but "server-rendered" only holds for content with no code in it, which is worth knowing before this goes into a route that has no client bundle yet. ── Why there is no parser in `dependencies` ── Three options, and the decision is written here because it is the one a reader will want to argue with. 1. BUNDLE A PARSER. markdown-it is the obvious pick, and the documentation site already depends on it — but the site is an app and this is a library, where the dependency list is part of the contract every consumer signs. Measured with the same esbuild pass `check:size` uses, markdown-it is 110.7 kB minified against the 38.9 kB this package had left under its bundle budget. It is not close, and "we will raise the budget" is how a design system becomes the largest thing on a page. 2. TAKE PRE-PARSED HTML, the way `Article` does. Cheapest, and wrong for the case this exists for: HTML from an untrusted author is the input this is supposed to make safe, and it cannot carry `headingLevelStart` or stable heading ids without the component parsing markup back apart to find them. 3. PARSE THE SUBSET WE STYLE, and take a `parse` function for the rest. Chosen. The block grammar is line-based and small (see `parse.ts`), it emits React elements rather than markup — so there is no `dangerouslySetInnerHTML` in this path at all, no sanitiser to configure and no sanitiser to get wrong — and a consumer who needs tables, footnotes or task lists passes `parse` and keeps every other promise the component makes. Headings carry an `id` slugged from their own text and deduplicated within the document, so a table of contents can link to them without the caller post-processing the DOM to find out where they went. A link in the source is validated here, at the boundary: a scheme that is not `http`, `https`, `mailto` or `tel` never becomes a link, and one that leaves for another site carries `rel="noreferrer nofollow"` — so an author the page does not trust cannot spend its ranking or read its URL out of the `Referer`.
### Props
- `children` (required) — `string`. The Markdown source. A string — not nodes, not HTML.
- `headingLevelStart` — `MarkdownHeadingLevel` default `1`. The level the document's top heading renders at. Markdown is written as a document, so its `#` is an `
`. Dropped into a page that already has one — inside an `
` section, in a card, under a dialog's title — that produces two first-level headings and an outline a screen reader cannot navigate. Set this to the level BELOW the heading the content sits under, and every level in the document shifts with it.
- `idPrefix` — `string`. Namespaces the generated heading ids. Two documents on one page both containing "Installation" would otherwise both claim `#installation`, and a fragment link would land on whichever the browser found first.
- `markExternalLinks` — `boolean` default `false`. Adds the system's outbound arrow to links that leave for another site. Off by default, and opt-in rather than automatic for two reasons. The mark is an addition to a sentence the component did not write, which is a different kind of act from styling one. And `Markdown` has no idea what origin it is being rendered on, so "leaves for another site" can only mean "carries an `http`/`https` scheme" — an absolute link back to your own domain gets the arrow too. A caller who knows the shape of their content turns it on; one who does not should not have it turned on for them. The `rel` on an outbound link is NOT opt-in and cannot be turned off: it is the security half of the same boundary as the scheme check.
- `parse` — `(source: string) => MarkdownNode[]` default `parseMarkdown`. Bring your own parser. Anything that turns the source into `MarkdownNode`s: markdown-it, remark, or a pipeline that already has an AST and only needs mapping. The node shapes are exported alongside the component as `MarkdownNode`, `MarkdownInline` and `MarkdownHeadingLevel`. The built-in parser covers the subset this system styles; this is the seam for tables, footnotes, task lists and everything else.
## Example — a string into nodes
```tsx
import { Article, Markdown } from '@misoto22/design'
{SOURCE}
```
## Example — a readme fragment
```tsx
import { Article, Markdown } from '@misoto22/design'
{README}
```
## Example — an answer mid stream
```tsx
import { Markdown } from '@misoto22/design'
{ANSWER}
```
## Example — shifting the outline
```tsx
import { Article, Heading, Markdown, slugify } from '@misoto22/design'
Getting started{GUIDE}
```
## Example — your own parser
```tsx
import { Article, Markdown, parseMarkdown, type MarkdownNode } from '@misoto22/design'
{CHECKLIST}
```