Text

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.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

export type ProseTag = "article" | "aside" | "div" | "main" | "section"
export type ProseSize = "sm" | "base" | "lg"
export type ProseMeasure = "narrow" | "default" | "wide" | "full"

type Density = "comfortable" | "compact"

/**
 * Root font size plus the h1–h4 scale. Every other size in the sheet is written
 * in `em`, so it rides this one value instead of needing its own column.
 */
const SIZE: Record<ProseSize, string> = {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prose.json

Prompt

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; compact and 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 a 0 in 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 dangerouslySetInnerHTML cannot coexist on one element, so Prose picks: 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-prose hatch: descendant rules reach every element inside, so React islands belong outside the wrapper.

On This Page