Prose
Styles the HTML a markdown renderer produces, so long-form content picks up the design system's type, spacing, and color.
Installation
Usage
import { Prose } from "@/components/ui/prose"Examples
Default
Headings, paragraphs, inline code, a code block, a blockquote, a list, and a table, all styled from the tokens with no per-element classes.
Example
Paste a raw meeting transcript and get back a clean recap. The renderer turns markdown into HTML; Prose gives it the type, spacing, and color from your design system.
Alice: We decided to launch the beta on Friday.
Bob: The mobile app will be postponed to next quarter.
Carol: Priya will finalize the release notes by Thursday.
Dave: Sam will email beta testers by Friday.Recap: the team agreed to launch the beta on Friday and to postpone the mobile app to next quarter. See the full thread for context.
Ship the smallest thing that proves the idea, then decide what is next.
Decisions
- Launch beta on Friday
- Mobile app postponed to next quarter
Action items
| Owner | Task | Due |
|---|---|---|
| Priya | Finalize release notes | Thu |
| Sam | Email beta testers | Fri |
Inline formatting
Bold, italic, strikethrough, inline code, links, an ordered list, and a rule.
Inline formatting covers bold, italic, struck-through, inline code, and links. Ordered lists number themselves:
- Draft the recap
- Confirm the owners
- Send by end of day
A rule breaks one section from the next without adding a heading.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Merged onto the root. Pass max-w-none to drop the reading-measure cap, or override any element style. |
| children* | ReactNode | - | The rendered HTML to style. Typically the output of a markdown renderer. |
import { cn } from "@/registry/lib/utils"
/**
* Prose styles the HTML that a markdown renderer produces. Drop the rendered
* output inside and every element (headings, paragraphs, links, code, lists,
* blockquotes, tables) picks up the design system's tokens. It does not parse
* markdown; pair it with whatever renderer the app already uses.
*
* Spacing comes from `prose-flow` (vertical rhythm owned by the following
* element) and the block caps at the reading measure. Wide code blocks and
* tables scroll inside that measure rather than overflow the page. To opt out
* of the cap, pass `className="max-w-none"`.
*/
// Element styling. Grouped by element, each rule token-driven so a re-themed
// consumer inherits the change. Descendant selectors are the only way to reach
// HTML this component did not author, so the styling lives here, not on tags.
const proseStyles = [
// Headings: strong, balanced wrapping, tight leading. Real weights only.
"[&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground [&_h1]:text-pretty",
"[&_h2]:text-xl [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground [&_h2]:text-pretty",
"[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:leading-snug [&_h3]:text-foreground [&_h3]:text-pretty",
"[&_h4]:text-base [&_h4]:font-semibold [&_h4]:leading-snug [&_h4]:text-foreground [&_h4]:text-pretty",
"[&_h5]:text-sm [&_h5]:font-semibold [&_h5]:leading-snug [&_h5]:text-foreground [&_h5]:text-pretty",
"[&_h6]:text-sm [&_h6]:font-semibold [&_h6]:leading-snug [&_h6]:text-muted-foreground [&_h6]:text-pretty",
// No size here on purpose: paragraphs take the block's reading size from the
// root below, so the two cannot drift apart. Only the wrapping is set, to
// keep orphan words out. Color comes from the muted-foreground root;
// headings, bold, and links step back up to foreground/accent explicitly.
"[&_p]:text-pretty",
// Inline emphasis. Bold and italic use loaded weights, never synthesized.
"[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground",
"[&_em]:italic [&_i]:italic",
"[&_del]:text-muted-foreground [&_del]:line-through [&_s]:text-muted-foreground [&_s]:line-through",
// Links: the kit's inline-link rule. Accent, underlined at rest, plain on
// hover; the underline pulls its weight from the font metrics.
"[&_a]:text-accent [&_a]:underline [&_a]:decoration-from-font [&_a]:underline-offset-2 [&_a]:rounded-sm [&_a:hover]:no-underline",
"[&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-ring-accent [&_a:focus-visible]:outline-none",
// Inline code: the same chip the docs site's `InlineCode` draws (an accent
// tint on accent-100 / accent-900, accent label, medium weight, 0.85em),
// so a code word inside rendered markdown and a prop name in an API table
// are one chip. It used to be a neutral chip that inherited the sentence's
// size, and the two read as two different systems on the same page. The
// recipe and its dark-mode numbers live on `InlineCode`; change them there
// first, then here, and keep the two identical. The pre reset below keeps
// this off code blocks.
"[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-accent-100 dark:[&_:not(pre)>code]:bg-accent-900 [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.85em] [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:font-medium [&_:not(pre)>code]:text-accent dark:[&_:not(pre)>code]:text-accent-400",
// Code blocks: the Card tier (ringed surface, rounded-lg), matching the
// CodeBlock component. Wide lines scroll inside the block. The inner code
// resets to the code-block type (text-xs, relaxed leading).
"[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-card [&_pre]:p-4 [&_pre]:text-sm [&_pre]:shadow-ring-md",
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:font-mono [&_pre_code]:font-normal [&_pre_code]:text-xs [&_pre_code]:leading-relaxed [&_pre_code]:text-inherit",
// Lists: logical inline padding (RTL-safe, tight so the marker sits close to
// the first word), muted markers matching the muted body, tight item gaps.
"[&_ul]:ps-4 [&_ul]:list-disc [&_ol]:ps-4 [&_ol]:list-decimal [&_li]:marker:text-muted-foreground",
"[&_li+li]:mt-2 [&_li>p]:m-0 [&_li>ul]:mt-2 [&_li>ol]:mt-2",
// A code block or table inside an item: `prose-flow` spaces figures only
// between the block's DIRECT children, so a nested one got no gap and its
// ringed edge sat on the line above it. 12px, a step past the 8px item gap,
// so the box reads as part of its item rather than a peer of the next one.
// On the element itself rather than as `* + pre`, because a tight list emits
// `<li>Lead-in:<pre>` and a text node is not a sibling a selector can see.
// The bottom margin collapses through the `li` into the next item's gap.
"[&_li>pre]:my-3 [&_li>table]:my-3",
// GFM task lists carry their own checkbox, so drop the bullet and the indent
// that a bullet would need.
"[&_ul:has(input[type=checkbox])]:list-none [&_ul:has(input[type=checkbox])]:ps-0 [&_li:has(input[type=checkbox])]:list-none",
// Blockquote: the kit's one set-apart-prose treatment (accent rule, indent,
// muted voice), the same chrome the docs site's Callout uses. Both exist
// because they reach different content: this one styles markdown a renderer
// produced, Callout is authored directly. The values have to stay identical.
"[&_blockquote]:border-s-2 [&_blockquote]:border-accent [&_blockquote]:ps-4 [&_blockquote]:text-muted-foreground",
"[&_blockquote>*]:m-0 [&_blockquote>*+*]:mt-3",
// Tables: mirror the Table component. Ringed card container, muted micro-cap
// header, muted-foreground cells on the 24/12 padding rhythm, last row
// unbordered. Sizes to content but never wider than the measure; scrolls
// within it on narrow screens.
"[&_table]:block [&_table]:w-max [&_table]:max-w-full [&_table]:overflow-x-auto [&_table]:rounded-md [&_table]:border [&_table]:border-border [&_table]:bg-card [&_table]:shadow-xs [&_table]:text-sm",
"[&_thead]:bg-muted [&_th]:h-11 [&_th]:px-6 [&_th]:text-start [&_th]:align-middle [&_th]:font-semibold [&_th]:text-2xs [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wide [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border",
"[&_td]:px-6 [&_td]:py-3 [&_td]:align-middle [&_td]:text-muted-foreground [&_td]:border-b [&_td]:border-border [&_tbody_tr:last-child_td]:border-0",
// Divider: a hairline on the border token, matching Separator.
"[&_hr]:h-px [&_hr]:border-0 [&_hr]:bg-border",
// Images: responsive, with the kit's neutral hairline outline.
"[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:ring-1 [&_img]:ring-black/10 dark:[&_img]:ring-white/10",
].join(" ")
// @use-when rendered markdown, or any long-form HTML you did not author
// element by element.
function Prose({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="prose"
className={cn(
// text-base is the READING size for the whole block, so list items and
// blockquotes inherit it alongside paragraphs. Setting it on `p` alone
// would leave lists a step smaller than the prose around them.
// Descendants that are NOT reading copy (h5/h6, pre, table) keep their
// own smaller size explicitly below.
"prose-flow max-w-measure text-base text-muted-foreground break-words [font-synthesis:none]",
proseStyles,
className,
)}
{...props}
/>
)
}
export { Prose }