Display
Markdown
A Markdown string, rendered as this system’s components.
Usage
When to reach for it
import { Markdown } from '@misoto22/design'Notes
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 <pre> 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 <Article as="div"> 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.
Anatomy
| Element | Description |
|---|---|
| Fragmentrequired | 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. |
| Parserrequired | 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. |
| Nodesrequired | 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 idsrequired | 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 <h1>; 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 — <Article as="div"> for a comment or an answer, <Article> 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; <Article as="div"> 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.
Examples
a string into nodes
A string nobody on this side wrote, rendered as the system's own components — no dangerouslySetInnerHTML anywhere in the path, so there is no sanitiser to configure and none to get wrong. headingLevelStart is 2 because this sits under the page's own headings, and the ids the headings get are slugged from their text so a table of contents can link into them. Article is the reading column; Markdown makes the nodes that sit in it, which is why it renders no element of its own.
Release notes
The parser covers the subset this system styles: headings, prose, code, strong, links, lists and fences. A link pointing at a scheme that is not http, https, mailto or tel renders as text instead.
- One rung of the ink ladder per tone
- A hairline dash for a marker, never a filled disc
Depth is a hairline and a change of ground, never a blur.
pnpm add @misoto22/designa readme fragment
A README, straight off a repository, rendered as this system's own components. Nested lists, an ordered list, a fence with its language, inline code and a link all come out of the built-in parser, and none of it goes through dangerouslySetInnerHTML — the nodes are React elements, so there is no sanitiser to configure and none to get wrong. headingLevelStart is 2 because the document's own # would otherwise be a second h1 on this page.
@misoto22/design
A monochrome design system, published as one package and one stylesheet.
Install
pnpm add @misoto22/designThen import the compiled look once, at the root of the app:
@misoto22/design/styles.css— tokens, fonts and the compiled utilitiesOr the portable layers on their own:
tokens.cssfor the primitivessemantic.cssfor the roles
Everything exported from the package is a consumer contract: adding an export is cheap, and moving one is a major version.
an answer mid stream
A model's answer, cut off mid-fence by a token limit — which renders as a code block rather than as an exception, because an unclosed fence runs to the end of the document exactly as CommonMark says. Nothing here is a document, so there is no Article: the fragment goes straight into the box the thread already has, and that box supplies the gap, since Markdown renders no element of its own and every paragraph it makes is margin: 0.
Use Tag for the facets and keep the button outside it, so the padding stays on the chip and the target stays on the control.
- Wrap each tag in a real button
- Pass
aria-pressedin the same breath asactive - Leave one facet off, so the accent still means something
<button type="button" aria-pressed={on} onClick={toggle}> <Tag active={on}>Rust</Tag>shifting the outline
The same document under a heading that already exists. headingLevelStart is the level BELOW the one it sits under, so the guide's # renders as h3 and every level in it shifts together — dropped in unshifted, it would be a second first-level heading and an outline a screen reader cannot navigate. idPrefix namespaces the generated ids so two documents on one page cannot both claim #installing, and slugify is exported for exactly this: the table of contents arrives at the same ids without reading them back off the DOM.
Getting started
Installing
Add the package, then import the stylesheet once at the root of the app.
Upgrading
Minor versions add exports and never move one, so an upgrade inside a major is a version bump and nothing else.
your own parser
Task lists are not in the built-in grammar, so a bracketed x arrives as literal text and nothing else happens — quiet rather than broken, which is the shape every unsupported feature takes here. parse is the seam: any function from a string to MarkdownNode values, so it takes a whole pipeline (markdown-it, remark, an AST you already have) or, as here, a rewrite of the one thing the grammar does not know before the parser that ships handles the rest.
Before 0.5.0
- ✓ Tokens rebuilt from the CSS source
- ✓ Every example carries the sentence that explains it
- ○ Chinese copy for the five newest components
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.