Prose
A typographic wrapper for CMS or Markdown HTML: token-only vertical rhythm, heading scale, lists, quotes, code, tables and figures, with size, measure and compact axes.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prose.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Prose" component: a typographic wrapper
for HTML you did not write (a CMS field, a Markdown renderer, a rich-text
editor). No @tailwindcss/typography, no Markdown library, no sanitiser, no
hooks, no "use client" — the only import beyond React is the shared cn()
class-merge helper.
Contract
- Named export `Prose`, forwardRef<HTMLElement, ProseProps>, with ProseProps
extending React.HTMLAttributes<HTMLElement>:
- as?: "article" | "aside" | "div" | "main" | "section" — default "div".
The landmark is a document-structure decision; the component never guesses
one on the caller's behalf.
- size?: "sm" | "base" | "lg" — default "base". Root font size plus the
h1..h4 scale.
- measure?: "narrow" | "default" | "wide" | "full" — default "default".
Line-length cap: 58ch / 72ch / 86ch / none.
- compact?: boolean — default false. Tightens the rhythm and the leading.
- html?: string — pre-rendered, ALREADY SANITISED markup, injected with
dangerouslySetInnerHTML. Ignored when children are present.
- children?: ReactNode — rich-text JSX instead of a string.
- Root carries data-slot="prose"; the remaining props spread onto the element.
- The component is one class attribute. Ship it as three lookup records
(SIZE, MEASURE, FLOW) plus grouped constant strings (RHYTHM, HEADINGS,
INLINE, LINKS, LISTS, CODE, TABLES, BLOCKS) joined through cn(), so a
consumer's className merges last and wins.
Behavior — content precedence
- Compute `React.Children.count(children) === 0 && html !== undefined &&
html.trim() !== "" ? html : null`. Non-null means render
<Tag dangerouslySetInnerHTML={{__html: inlined}} /> with no children; null
means render <Tag>{children}</Tag>. Two separate returns, not one element
with both props: React throws when children and dangerouslySetInnerHTML are
both set, so decide it here instead of at the call site.
- children always beat html. A blank or whitespace-only html string counts as
absent and yields an empty wrapper with zero height — every margin in the
sheet belongs to a child, so nothing is left behind.
- The component never sanitises. Say so in the prop's JSDoc: DOMPurify or
sanitize-html runs upstream, ideally on the server.
Behavior — rhythm maths (one custom property)
- The root sets --prose-flow from a (size x density) table, one class per pair:
sm 0.875rem comfortable / 0.5rem compact
base 1.25rem comfortable / 0.75rem compact
lg 1.5rem comfortable / 0.875rem compact
One class per pair, not two competing [--prose-flow:...] utilities on the
same element: identical specificity would leave the winner to stylesheet
order.
- Blocks (p, ul, ol, dl, pre, table, figure, blockquote) get
margin-bottom: var(--prose-flow) and no margin-top.
- Headings get a top margin of flow x 2 / 1.8 / 1.4 / 1.2 (h1..h4, h5 and h6
x 1.2) and a bottom margin of flow x 0.6 / 0.5 / 0.4 / 0.35 (h5, h6 x 0.3).
- Adjacent siblings collapse to the larger of the pair, which is the whole
point of bottom-margin-only blocks: paragraph -> heading = 1.8 flow,
heading -> paragraph = 0.5 flow, paragraph -> paragraph = 1 flow. A
top-margin-only sheet cannot tighten the heading-to-paragraph gap without
adjacent-sibling rules that tie on specificity with the element rules and
resolve by stylesheet order.
- Inside containers the rhythm runs at about a third: li bottom = flow x 0.35,
nested ul/ol top = flow x 0.35, dt top = flow x 0.5, dd bottom = flow x 0.5,
hr block margin = flow x 2.
- Outer edges: `> :first-child { margin-top: 0 }` and
`> :last-child { margin-bottom: 0 }`. At (0,2,0) they beat every element rule
at (0,1,1) whatever the pipeline emits first, so the wrapper's own padding
owns the outside. Do the same for `li > :last-child`,
`blockquote > :last-child`, `figure > :last-child` and `dt:first-child`.
- Assume Tailwind Preflight has zeroed the UA margins. Without Preflight, add
the matching [&_p]:mt-0-style resets before the rhythm group.
Behavior — sizing maths
- size sets only the root font size and h1..h4 (sm: xl/lg/base/sm,
base: 3xl/2xl/xl/base, lg: 4xl/3xl/2xl/lg). Everything else is written in em
— code and figcaption 0.875em, tables 0.9em, h6 0.9em, kbd 0.8em — so the
whole document re-scales from one axis instead of needing a third column.
- Headings MUST set their own leading (tight/snug). In Tailwind v4 a text-*
utility reads its line height from the inherited --tw-leading, which the
body's leading-relaxed already set, so without this a 30px heading wraps at
body leading.
- measure caps the column in ch, the advance width of "0" in the current font.
That makes it a character budget rather than a width: 72ch is the same
reading width at every size step, and 45-85 characters is the comfortable
range the numbers come from.
- Inline code inside a code block must be reset —
`pre code { background: transparent; padding: 0; border-radius: 0;
font-size: 1em }` at (0,1,2) beats `code` at (0,1,1). Skip it and the inline
pill lands on every block, with the two em sizes compounding to 0.77em.
Behavior — what it deliberately does not do
- No sanitising, no Markdown parsing, no syntax highlighting (it paints the
pre surface, not the tokens), no heading-level rewriting (an h1 in the source
stays an h1), no styling for form controls (input, select, button) or embeds
(iframe, embed, object).
- No `not-prose` escape hatch. The rules are plain descendant selectors, so a
React island rendered inside inherits them. Either keep islands outside the
wrapper, or append `:not(:where(.not-prose,.not-prose *))` to every selector
the way the typography plugin does.
- Cleanup: there is nothing to clean up. No state, no effects, no timers, no
observers, no listeners, no animation — so prefers-reduced-motion is moot and
the first server-rendered frame is already the final one.
Keyboard and ARIA contract
- The component adds no key handlers: this is content, not a control. What it
does own is the one thing the pipeline cannot — anchors arrive class-less, so
Tab must land on a visible ring: [&_a:focus-visible] gets outline-none +
ring-2 ring-ring + ring-offset-2 ring-offset-background, in document order.
- The wrapper takes no role: a styling container is not a landmark. as="article"
or as="main" is how a caller opts into one.
- Nothing is added to the accessibility tree and nothing is aria-hidden, so
screen-reader output equals the source markup. alt text, <abbr title>, table
<th> scope and heading order all come from the pipeline; the component styles
them, it cannot supply them.
- Known gap, state it honestly: the table's overflow-x:auto scroller is not
keyboard-scrollable in Chrome or Safari unless it is focusable, and CSS
cannot add a tabindex to injected HTML. If the pipeline can, have it wrap
wide tables in <div role="region" tabindex="0" aria-label="...">.
Degenerate cases
- children + html -> children render, html is dropped, React never sees two
content sources.
- html="" or html=" " -> empty wrapper, no phantom rhythm.
- Table wider than the container -> the table scrolls inside its own box.
- Long unbroken URL or token -> the root sets break-words, which inherits.
- <code> inside a heading -> 0.875em of the heading, not of the body.
- Nested Prose -> the inner --prose-flow wins by custom-property inheritance.
Rendering & styling (semantic tokens only, no hex / rgb / oklch)
- Root: text-foreground, break-words, the size class, the measure cap, the
--prose-flow class and leading-relaxed (leading-normal when compact).
- Headings: font-semibold, tracking-tight, text-balance on h1..h4; h6 is
0.9em uppercase tracking-wide text-muted-foreground.
- Inline: strong font-semibold text-foreground; del and small
text-muted-foreground; mark rounded-sm bg-primary/15 text-foreground;
abbr underline decoration-dotted; kbd border border-border bg-card
font-mono text-[0.8em] shadow-sm.
- Links: text-primary, underline, decoration-primary/40, underline-offset-4,
hover:decoration-primary, plus the focus ring above.
- Lists: list-disc / list-decimal with pl-6 and
[&_li]:marker:text-muted-foreground. GFM task lists get neither marker nor
indent — target them with the single arbitrary selector
[&_ul:has(>li>input)] (not two stacked variants, whose generated selector
depends on Tailwind's variant-ordering rules) and give the checkbox mr-2
align-middle accent-primary, because the checkbox IS the marker.
- Code: inline code rounded-sm bg-muted px-[0.35em] py-[0.15em] font-mono
0.875em; pre overflow-x-auto rounded-lg border border-border bg-muted p-4
leading-relaxed; then the pre code reset.
- Tables: display:block + width:max-content + max-w-full + overflow-x-auto
(GitHub's recipe) so a 12-column CMS table scrolls in its own box instead of
pushing a scrollbar onto the page; border-collapse, thead border-b
border-border, th px-3 py-2 text-left font-semibold whitespace-nowrap, td
px-3 py-2 align-top, tbody tr border-b with the last row cleared, caption
0.9em text-muted-foreground.
- Blocks: blockquote border-l-2 border-border pl-4 text-muted-foreground; img
and video max-w-full h-auto rounded-lg; figcaption mt-2 0.875em
text-muted-foreground; hr border-t border-border.
Customization levers
- --prose-flow is the single rhythm knob and it survives cn(): pass
className="[--prose-flow:2rem]" and the whole document re-times, no variant
needed. tailwind-merge keys arbitrary properties by property name, so the
consumer's declaration replaces the built-in one.
- measure: change the ch numbers, or move the cap off the wrapper and onto the
text elements only ([&_p]:max-w-[72ch] and friends) when you want figures and
tables to bleed wider than the column.
- Tables: swap the GitHub scroller for w-full table-fixed when you control the
content and would rather have cells stretch than scroll.
- Quotes: add italic (and swap border-border for border-primary) for an
editorial voice; the default stays upright because CMS quotes are often long
and long italic runs read slower.
- Density: add a "prose-xs" size row for email previews, or a third density
step; each is one line in SIZE / FLOW because everything else is em-relative.
- Colour: the sheet touches bg-muted (code surfaces), border-border,
text-muted-foreground and text-primary. Retheme by changing those tokens,
not the component — that is what the token-only rule buys you.
- Anchored headings: add [&_h2]:scroll-mt-24 and friends when a sticky header
would otherwise cover the target of a #hash link.
- Wide-table accessibility: if you also render the HTML yourself, wrap tables
in a focusable role="region" as described above; it is the one accessibility
gap CSS cannot close.Concepts
- One flow variable — every block gap is a multiple of
--prose-flow, so re-timing a whole document is one declaration rather than a pass over a dozen margin rules;compactand the size axis are just different values of that single property. - Bottom margins that collapse — blocks own only a bottom margin and headings add a top one, so each gap is the larger of the adjacent pair: a heading can sit tight to the paragraph it introduces while still keeping a full flow of air above it, without any adjacent-sibling rule that would tie on specificity.
- Measure is a character budget — the cap is written in
ch, the width of a0in the current font, so 72ch stays the same reading width when the size axis moves; pixels would have to be re-tuned for every step. - Content precedence, decided in the component — children and
dangerouslySetInnerHTMLcannot coexist on one element, soProsepicks: real children win, a blank html string counts as absent, and the call site never has to guard against React throwing. - Styling is not sanitising — the component paints markup it was handed and nothing else; XSS is stopped upstream by DOMPurify or sanitize-html, ideally on the server, and the prop's JSDoc says so where a consumer will actually read it.
- Declared non-coverage — form controls, embeds, syntax-highlighting tokens and heading levels are deliberately untouched, and there is no
not-prosehatch: descendant rules reach every element inside, so React islands belong outside the wrapper.